fix(super-admin): убрать цикл редиректа, регресс override после пакета задач

КОРНЕВАЯ ПРИЧИНА: SuperAdminLayout имел useEffect

  if (getOrgOverride()) navigate('/dashboard', { replace: true })

который автоматически выкидывал в tenant-режим при любом заходе на
/super-admin/* если в localStorage остался override (например с прошлой
сессии или после нечистого выхода). Это создавало цикл: SuperAdmin
кликает «Системная консоль» в меню → попадает на /super-admin/... →
useEffect видит override → navigate /dashboard → tenant TenantRouteGuard
проверяет: override есть → пропускает, но юзер видит tenant с баннером
вместо системной консоли.

Если override содержал «зависший» orgId с прошлой сессии и юзер ожидал
вернуться в SuperAdmin консоль — он попадал в tenant-режим, а попытки
вернуться через ссылки sidebar лечились этим же useEffect снова в /
dashboard. Юзер видел тост от TenantRouteGuard в редких случаях когда
localStorage не успевал прочитаться (race с rAF).

Фикс:
- Удалён useEffect (+ unused useNavigate импорт). SuperAdmin может
  свободно перемещаться между системной консолью и tenant-режимом
  при активном override; снимает override только баннером «Выйти из
  режима» либо переключает другую орг через picker.
- TenantRouteGuard: повторная проверка getOrgOverride() через
  requestAnimationFrame (защита от любых race с localStorage), и
  разовый guard-флаг (useRef) чтобы не редиректить дважды на одной
  странице. Console.warn перед редиректом — облегчает диагностику
  если регресс повторится.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 17:54:37 +05:00
parent 79016579c2
commit 2cadb6e5d9
2 changed files with 31 additions and 18 deletions

View file

@ -1,12 +1,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom' import { NavLink, Outlet, useLocation } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { import {
ShieldCheck, Building, FileClock, HeartPulse, HardDriveDownload, ShieldCheck, Building, FileClock, HeartPulse, HardDriveDownload,
Settings, Users, LayoutDashboard, LogOut, Menu, X, ChevronDown, Globe, Settings, Users, LayoutDashboard, LogOut, Menu, X, ChevronDown, Globe,
FolderTree, Ruler, FolderTree, Ruler,
} from 'lucide-react' } from 'lucide-react'
import { api, getOrgOverride, setOrgOverride } from '@/lib/api' import { api, setOrgOverride } from '@/lib/api'
import { logout } from '@/lib/auth' import { logout } from '@/lib/auth'
import { useMe } from '@/lib/useMe' import { useMe } from '@/lib/useMe'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -40,7 +40,6 @@ interface OrgRow { id: string; name: string; isArchived: boolean }
export function SuperAdminLayout() { export function SuperAdminLayout() {
const me = useMe() const me = useMe()
const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [orgPickerOpen, setOrgPickerOpen] = useState(false) const [orgPickerOpen, setOrgPickerOpen] = useState(false)
@ -55,11 +54,12 @@ export function SuperAdminLayout() {
return () => { document.title = prev } return () => { document.title = prev }
}, [location.pathname]) }, [location.pathname])
// Если у юзера активен override — это режим «открыто как…», SuperAdmin // Раньше тут стоял useEffect navigate('/dashboard') если getOrgOverride()
// должен видеть tenant-layout, а не системную консоль. Перебрасываем. // не пуст — это создавало цикл: SuperAdmin не мог зайти в системную консоль
useEffect(() => { // когда override остался с прошлой сессии (его выкидывало обратно в
if (getOrgOverride()) navigate('/dashboard', { replace: true }) // tenant-режим, баннер с «Выйти из режима» тоже в tenant-области).
}, [navigate, location.pathname]) // Теперь переход в системную консоль безопасен — override остаётся, юзер
// его сам снимает через баннер либо переключает другую орг через picker.
// Закрывать drawer при смене маршрута. // Закрывать drawer при смене маршрута.
useEffect(() => { setDrawerOpen(false) }, [location.pathname]) useEffect(() => { setDrawerOpen(false) }, [location.pathname])

View file

@ -1,23 +1,36 @@
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useMe } from '@/lib/useMe' import { useMe } from '@/lib/useMe'
import { getOrgOverride } from '@/lib/api' import { getOrgOverride } from '@/lib/api'
/** Не пускает SuperAdmin'а в tenant-роуты без активного override /** Не пускает SuperAdmin'а в tenant-роуты без активного override.
* SuperAdmin сам по себе не сотрудник конкретной орги, любая tenant-страница * Защита от race: между window.location.assign('/dashboard') и тем
* для него бессмысленна. Если override не активен редирект на * моментом когда сюда докатилось me.data localStorage уже точно
* /super-admin/organizations с alert'ом «Откройте организацию через "Открыть как"». */ * установлен. На всякий случай делаем повторную проверку через rAF
* перед редиректом раньше были репорты что после Phase 2c сценарий
* клика «Открыть как» снова приводит к alert'у, оказалось это был
* цикл редиректа в SuperAdminLayout (убран отдельным фиксом). */
export function TenantRouteGuard({ children }: { children: React.ReactNode }) { export function TenantRouteGuard({ children }: { children: React.ReactNode }) {
const me = useMe() const me = useMe()
const navigate = useNavigate() const navigate = useNavigate()
const checkedOnceRef = useRef(false)
useEffect(() => { useEffect(() => {
if (!me.data) return if (!me.data) return
const isSuper = me.data.roles?.includes('SuperAdmin') const isSuper = me.data.roles?.includes('SuperAdmin')
if (isSuper && !getOrgOverride()) { if (!isSuper) return
// Маленькое уведомление перед редиректом — alert удобнее тоста для разовой ситуации. // Re-read через rAF чтобы перебить любые гонки с инициализацией localStorage.
try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ } requestAnimationFrame(() => {
navigate('/super-admin/organizations', { replace: true }) const ov = getOrgOverride()
} if (!ov) {
if (checkedOnceRef.current) return
checkedOnceRef.current = true
// eslint-disable-next-line no-console
console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations',
{ hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') })
try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ }
navigate('/super-admin/organizations', { replace: true })
}
})
}, [me.data, navigate]) }, [me.data, navigate])
return <>{children}</> return <>{children}</>
} }