From 3d7166740ed46313e4f370cd4465d117b84766c0 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 12 Mar 2026 19:56:05 +0800 Subject: [PATCH] Fix lock screen flash, theme flicker, and lock state gating Gate dashboard rendering on isLockResolved to prevent content flash before lock state is known. Remove animate-fade-in from LockOverlay so it renders instantly. Always write accent color to localStorage (even default cyan) to prevent theme flash on reload. Resolve lock state on auth query error to avoid permanent blank screen. Lift mobileOpen state above lock gate to survive lock/unlock cycles. Co-Authored-By: Claude Opus 4.6 --- backend/app/routers/auth.py | 10 +++ frontend/src/App.tsx | 12 +-- frontend/src/components/layout/AppLayout.tsx | 82 ++++++++++++------- .../src/components/layout/LockOverlay.tsx | 2 +- frontend/src/hooks/useLock.tsx | 38 +++++---- frontend/src/hooks/useTheme.ts | 28 +++---- frontend/src/lib/api.ts | 4 + 7 files changed, 106 insertions(+), 70 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index e944aed..0e9c847 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -135,6 +135,16 @@ async def get_current_user( # Stash session on request so lock/unlock endpoints can access it request.state.db_session = db_session + # Defense-in-depth: block API access while session is locked. + # Exempt endpoints needed for unlocking, locking, checking status, and logout. + if db_session.is_locked: + lock_exempt = { + "/api/auth/lock", "/api/auth/verify-password", + "/api/auth/status", "/api/auth/logout", + } + if request.url.path not in lock_exempt: + raise HTTPException(status_code=423, detail="Session is locked") + return user diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98d7a3e..1bb5810 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,11 +20,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { const { authStatus, isLoading } = useAuth(); if (isLoading) { - return ( -
-
Loading...
-
- ); + return
; } if (!authStatus?.authenticated) { @@ -38,11 +34,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) { const { authStatus, isLoading } = useAuth(); if (isLoading) { - return ( -
-
Loading...
-
- ); + return
; } if (!authStatus?.authenticated || authStatus?.role !== 'admin') { diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 544f7da..01870ac 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -3,7 +3,7 @@ import { Outlet } from 'react-router-dom'; import { Menu } from 'lucide-react'; import { useTheme } from '@/hooks/useTheme'; import { AlertsProvider } from '@/hooks/useAlerts'; -import { LockProvider } from '@/hooks/useLock'; +import { LockProvider, useLock } from '@/hooks/useLock'; import { NotificationProvider } from '@/hooks/useNotifications'; import { Button } from '@/components/ui/button'; import Sidebar from './Sidebar'; @@ -11,45 +11,67 @@ import AppAmbientBackground from './AppAmbientBackground'; import LockOverlay from './LockOverlay'; import NotificationToaster from '@/components/notifications/NotificationToaster'; -export default function AppLayout() { - useTheme(); +function AppContent({ mobileOpen, setMobileOpen }: { + mobileOpen: boolean; + setMobileOpen: (v: boolean) => void; +}) { + const { isLocked, isLockResolved } = useLock(); const [collapsed, setCollapsed] = useState(() => { try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); } catch { return false; } }); + + // Don't render any content until we know the lock state + if (!isLockResolved || isLocked) { + return ( + <> +
+ {isLockResolved && } + + ); + } + + return ( + <> +
+ { + const next = !collapsed; + setCollapsed(next); + localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next)); + }} + mobileOpen={mobileOpen} + onMobileClose={() => setMobileOpen(false)} + /> +
+ + {/* Mobile header */} +
+ +

UMBRA

+
+
+ +
+
+
+ + + ); +} + +export default function AppLayout() { + useTheme(); const [mobileOpen, setMobileOpen] = useState(false); return ( -
- { - const next = !collapsed; - setCollapsed(next); - localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next)); - }} - mobileOpen={mobileOpen} - onMobileClose={() => setMobileOpen(false)} - /> -
- - {/* Mobile header */} -
- -

UMBRA

-
-
- -
-
-
- - +
diff --git a/frontend/src/components/layout/LockOverlay.tsx b/frontend/src/components/layout/LockOverlay.tsx index 3108ded..e48bc7b 100644 --- a/frontend/src/components/layout/LockOverlay.tsx +++ b/frontend/src/components/layout/LockOverlay.tsx @@ -56,7 +56,7 @@ export default function LockOverlay() { }; return ( -
+
diff --git a/frontend/src/hooks/useLock.tsx b/frontend/src/hooks/useLock.tsx index d20ce11..cdb2e1e 100644 --- a/frontend/src/hooks/useLock.tsx +++ b/frontend/src/hooks/useLock.tsx @@ -14,7 +14,8 @@ import type { AuthStatus } from '@/types'; interface LockContextValue { isLocked: boolean; - lock: () => void; + isLockResolved: boolean; + lock: () => Promise; unlock: (password: string) => Promise; } @@ -32,6 +33,11 @@ export function LockProvider({ children }: { children: ReactNode }) { return cached?.is_locked ?? false; }); + // Track whether lock state has been definitively resolved from the server + const [isLockResolved, setIsLockResolved] = useState(() => { + return queryClient.getQueryData(['auth']) !== undefined; + }); + const { settings } = useSettings(); const activeMutations = useIsMutating(); @@ -40,31 +46,33 @@ export function LockProvider({ children }: { children: ReactNode }) { const activeMutationsRef = useRef(activeMutations); activeMutationsRef.current = activeMutations; - // Sync lock state when auth status is fetched/refetched (e.g. on page refresh) - useEffect(() => { - const cached = queryClient.getQueryData(['auth']); - if (cached?.is_locked && !isLocked) { - setIsLocked(true); - } - }, [queryClient, isLocked]); - - // Subscribe to auth query updates to catch server lock state changes + // Subscribe to auth query updates to catch server lock state on refresh useEffect(() => { const unsubscribe = queryClient.getQueryCache().subscribe((event) => { if ( event.type === 'updated' && - event.action.type === 'success' && + (event.action.type === 'success' || event.action.type === 'error') && event.query.queryKey[0] === 'auth' ) { - const data = event.query.state.data as AuthStatus | undefined; - if (data?.is_locked) { - setIsLocked(true); + setIsLockResolved(true); + if (event.action.type === 'success') { + const data = event.query.state.data as AuthStatus | undefined; + if (data?.is_locked) { + setIsLocked(true); + } } } }); return unsubscribe; }, [queryClient]); + // Listen for 423 responses from the API interceptor (server-side lock enforcement) + useEffect(() => { + const handler = () => setIsLocked(true); + window.addEventListener('umbra:session-locked', handler); + return () => window.removeEventListener('umbra:session-locked', handler); + }, []); + const lock = useCallback(async () => { setIsLocked(true); try { @@ -139,7 +147,7 @@ export function LockProvider({ children }: { children: ReactNode }) { }, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]); return ( - + {children} ); diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts index afecf67..250075b 100644 --- a/frontend/src/hooks/useTheme.ts +++ b/frontend/src/hooks/useTheme.ts @@ -15,22 +15,22 @@ const ACCENT_PRESETS: Record = { export function useTheme() { const { settings } = useSettings(); + // Ensure localStorage always has an accent color (even default cyan) + // so the inline script in index.html can prevent flashes on every load useEffect(() => { - if (!settings?.accent_color) return; + const colorName = settings?.accent_color || 'cyan'; + const preset = ACCENT_PRESETS[colorName]; + if (!preset) return; - const preset = ACCENT_PRESETS[settings.accent_color]; - if (preset) { - const h = preset.h.toString(); - const s = `${preset.s}%`; - const l = `${preset.l}%`; - document.documentElement.style.setProperty('--accent-h', h); - document.documentElement.style.setProperty('--accent-s', s); - document.documentElement.style.setProperty('--accent-l', l); - // Cache for next page load to prevent cyan flash - try { - localStorage.setItem('umbra-accent-color', JSON.stringify({ h, s, l })); - } catch {} - } + const h = preset.h.toString(); + const s = `${preset.s}%`; + const l = `${preset.l}%`; + document.documentElement.style.setProperty('--accent-h', h); + document.documentElement.style.setProperty('--accent-s', s); + document.documentElement.style.setProperty('--accent-l', l); + try { + localStorage.setItem('umbra-accent-color', JSON.stringify({ h, s, l })); + } catch {} }, [settings?.accent_color]); return { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7ff73d2..60d29c8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,6 +20,10 @@ api.interceptors.response.use( window.location.href = '/login'; } } + // 423 = session is locked server-side — trigger lock screen + if (error.response?.status === 423) { + window.dispatchEvent(new CustomEvent('umbra:session-locked')); + } return Promise.reject(error); } );