feat(super-admin): рабочий quick-switch + UI-блокировка мутаций в read-only
Some checks are pending
Some checks are pending
БАГ: 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:
parent
9d89a2aeee
commit
4dafdc8995
|
|
@ -1,5 +1,6 @@
|
|||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useReadOnly } from '@/lib/useReadOnly'
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
type Size = 'sm' | 'md'
|
||||
|
|
@ -7,6 +8,10 @@ type Size = 'sm' | 'md'
|
|||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant
|
||||
size?: Size
|
||||
/** Кнопка инициирует мутацию данных. В read-only режиме SuperAdmin'а
|
||||
* (override без edit-mode) такие кнопки автоматически disabled с tooltip.
|
||||
* Нав-кнопки/закрытие модалок/Cancel — оставлять без этого пропа. */
|
||||
mutating?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
|
|
@ -22,10 +27,20 @@ const sizes: Record<Size, string> = {
|
|||
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 (
|
||||
<button
|
||||
{...rest}
|
||||
disabled={disabled || blocked}
|
||||
title={blocked ? ro.reason : title}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
variants[variant],
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function SuperAdminAsOrgBanner() {
|
|||
<Lock className="w-3.5 h-3.5" /> Снять edit
|
||||
</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">
|
||||
<X className="w-3.5 h-3.5" /> Выйти
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -32,12 +32,17 @@ export function getOrgOverride(): OrgOverride | null {
|
|||
return raw ? JSON.parse(raw) as OrgOverride : 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))
|
||||
else { localStorage.removeItem(ORG_OVERRIDE_KEY); localStorage.removeItem(EDIT_MODE_KEY) }
|
||||
// Силой обновляем все вкладки/страницы — кэш TanStack Query построен по
|
||||
// tenant'у, нужен hard reload чтобы снять старые данные.
|
||||
if (typeof window !== 'undefined') window.location.reload()
|
||||
// Hard navigation чтобы (а) снести TanStack Query кэш, (б) гарантированно
|
||||
// выйти из текущего layout'а в нужный (override → tenant /dashboard,
|
||||
// выход → /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'
|
||||
|
|
|
|||
15
src/food-market.web/src/lib/useReadOnly.ts
Normal file
15
src/food-market.web/src/lib/useReadOnly.ts
Normal 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: 'Только просмотр. Включите редактирование в баннере сверху, чтобы менять данные.',
|
||||
}
|
||||
}
|
||||
|
|
@ -100,7 +100,7 @@ export function SuperAdminOrganizationsPage() {
|
|||
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{!r.isArchived && (
|
||||
<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">
|
||||
<LogIn className="w-4 h-4" />
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue