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 (
-
- );
+ return ;
}
if (!authStatus?.authenticated) {
@@ -38,11 +34,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth();
if (isLoading) {
- return (
-
- );
+ 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);
}
);