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); } );