feat(super-admin): рабочий quick-switch + UI-блокировка мутаций в read-only
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m6s
CI / Web (React + Vite) (push) Successful in 39s
Docker Web / Build + push Web (push) Successful in 31s
Docker Web / Deploy Web on stage (push) Successful in 11s

БАГ: dropdown «Открыть организацию» в topbar системной консоли вёл
сначала на reload текущего /super-admin, а оттуда внутренний редирект
конкурировал с TenantRouteGuard'ом, и юзер видел alert «Откройте
через "Открыть как…"». Фикс — setOrgOverride получил опциональный
{ redirectTo }, делает window.location.assign(target) вместо reload.
Точки вызова обновлены:
- dropdown в SuperAdminLayout topbar → redirectTo='/dashboard'
- кнопка «Открыть как» в строке таблицы орг → redirectTo='/dashboard'
- кнопка «Выйти из режима» в баннере → redirectTo='/super-admin/organizations'

Хук useReadOnly() централизует «override активен И edit-mode не
включён» в один { readOnly, reason }. Button по умолчанию считает
variant='primary' и variant='danger' мутирующими (опт-аут через
mutating={false} для редких primary-без-мутаций) — в read-only они
автоматически disabled с tooltip «Только просмотр. Включите
редактирование в баннере…». Variant='secondary' и 'ghost' не
блокируются — Cancel/Назад/Закрыть остаются кликабельны. Серверный
403 (ReadonlyOverrideMiddleware) остаётся как safety-net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 15:32:29 +05:00
parent 9d89a2aeee
commit 4dafdc8995
5 changed files with 42 additions and 7 deletions

View file

@ -1,5 +1,6 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react' import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useReadOnly } from '@/lib/useReadOnly'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
type Size = 'sm' | 'md' type Size = 'sm' | 'md'
@ -7,6 +8,10 @@ type Size = 'sm' | 'md'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant variant?: Variant
size?: Size size?: Size
/** Кнопка инициирует мутацию данных. В read-only режиме SuperAdmin'а
* (override без edit-mode) такие кнопки автоматически disabled с tooltip.
* Нав-кнопки/закрытие модалок/Cancel оставлять без этого пропа. */
mutating?: boolean
children: ReactNode children: ReactNode
} }
@ -22,10 +27,20 @@ const sizes: Record<Size, string> = {
md: 'px-3.5 py-1.5 text-sm', md: 'px-3.5 py-1.5 text-sm',
} }
export function Button({ variant = 'primary', size = 'md', className, children, ...rest }: ButtonProps) { export function Button({ variant = 'primary', size = 'md', mutating, className, children, disabled, title, ...rest }: ButtonProps) {
const ro = useReadOnly()
// Variant primary/danger по умолчанию считаем мутирующими (Добавить/
// Сохранить/Удалить/Создать почти всегда primary либо danger). Secondary/
// ghost — нав-кнопки и Cancel — не блокируются. Явный mutating={false}
// нужен только в редких случаях когда primary не делает мутации (например
// «Применить фильтр», «Войти в режим…»).
const isMutating = mutating ?? (variant === 'primary' || variant === 'danger')
const blocked = isMutating && ro.readOnly
return ( return (
<button <button
{...rest} {...rest}
disabled={disabled || blocked}
title={blocked ? ro.reason : title}
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed', 'inline-flex items-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
variants[variant], variants[variant],

View file

@ -41,7 +41,7 @@ export function SuperAdminAsOrgBanner() {
<Lock className="w-3.5 h-3.5" /> Снять edit <Lock className="w-3.5 h-3.5" /> Снять edit
</button> </button>
)} )}
<button onClick={() => setOrgOverride(null)} <button onClick={() => setOrgOverride(null, { redirectTo: '/super-admin/organizations' })}
className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30"> className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30">
<X className="w-3.5 h-3.5" /> Выйти <X className="w-3.5 h-3.5" /> Выйти
</button> </button>

View file

@ -32,12 +32,17 @@ export function getOrgOverride(): OrgOverride | null {
return raw ? JSON.parse(raw) as OrgOverride : null return raw ? JSON.parse(raw) as OrgOverride : null
} catch { return null } } catch { return null }
} }
export function setOrgOverride(value: OrgOverride | null) { export function setOrgOverride(value: OrgOverride | null, opts?: { redirectTo?: string }) {
if (value) localStorage.setItem(ORG_OVERRIDE_KEY, JSON.stringify(value)) if (value) localStorage.setItem(ORG_OVERRIDE_KEY, JSON.stringify(value))
else { localStorage.removeItem(ORG_OVERRIDE_KEY); localStorage.removeItem(EDIT_MODE_KEY) } else { localStorage.removeItem(ORG_OVERRIDE_KEY); localStorage.removeItem(EDIT_MODE_KEY) }
// Силой обновляем все вкладки/страницы — кэш TanStack Query построен по // Hard navigation чтобы (а) снести TanStack Query кэш, (б) гарантированно
// tenant'у, нужен hard reload чтобы снять старые данные. // выйти из текущего layout'а в нужный (override → tenant /dashboard,
if (typeof window !== 'undefined') window.location.reload() // выход → /super-admin/organizations). Без redirectTo — просто reload
// на ту же страницу (старое поведение, для совместимости).
if (typeof window !== 'undefined') {
if (opts?.redirectTo) window.location.assign(opts.redirectTo)
else window.location.reload()
}
} }
const EDIT_MODE_KEY = 'superAdminEditMode' const EDIT_MODE_KEY = 'superAdminEditMode'

View file

@ -0,0 +1,15 @@
import { getOrgOverride, getEditMode } from '@/lib/api'
/** SuperAdmin зашёл в режим «открыто как» и edit-mode НЕ включён
* любые мутации в UI должны быть отключены. Возвращает { readOnly, reason }
* чтобы кнопки могли показать tooltip и причину. */
export function useReadOnly(): { readOnly: boolean; reason: string } {
const ov = getOrgOverride()
if (!ov) return { readOnly: false, reason: '' }
const edit = getEditMode()
if (edit && edit.expiresAt > Date.now()) return { readOnly: false, reason: '' }
return {
readOnly: true,
reason: 'Только просмотр. Включите редактирование в баннере сверху, чтобы менять данные.',
}
}

View file

@ -100,7 +100,7 @@ export function SuperAdminOrganizationsPage() {
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}> <div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
{!r.isArchived && ( {!r.isArchived && (
<button title="Открыть как… (read-only)" <button title="Открыть как… (read-only)"
onClick={() => setOrgOverride({ id: r.id, name: r.name })} onClick={() => setOrgOverride({ id: r.id, name: r.name }, { redirectTo: '/dashboard' })}
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"> className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded">
<LogIn className="w-4 h-4" /> <LogIn className="w-4 h-4" />
</button> </button>