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 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-12 19:56:05 +08:00
parent 89519a6dd3
commit 3d7166740e
7 changed files with 106 additions and 70 deletions

View File

@ -135,6 +135,16 @@ async def get_current_user(
# Stash session on request so lock/unlock endpoints can access it # Stash session on request so lock/unlock endpoints can access it
request.state.db_session = db_session 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 return user

View File

@ -20,11 +20,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth(); const { authStatus, isLoading } = useAuth();
if (isLoading) { if (isLoading) {
return ( return <div className="h-dvh bg-background" />;
<div className="flex h-dvh items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
} }
if (!authStatus?.authenticated) { if (!authStatus?.authenticated) {
@ -38,11 +34,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth(); const { authStatus, isLoading } = useAuth();
if (isLoading) { if (isLoading) {
return ( return <div className="h-dvh bg-background" />;
<div className="flex h-dvh items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
} }
if (!authStatus?.authenticated || authStatus?.role !== 'admin') { if (!authStatus?.authenticated || authStatus?.role !== 'admin') {

View File

@ -3,7 +3,7 @@ import { Outlet } from 'react-router-dom';
import { Menu } from 'lucide-react'; import { Menu } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { AlertsProvider } from '@/hooks/useAlerts'; import { AlertsProvider } from '@/hooks/useAlerts';
import { LockProvider } from '@/hooks/useLock'; import { LockProvider, useLock } from '@/hooks/useLock';
import { NotificationProvider } from '@/hooks/useNotifications'; import { NotificationProvider } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
@ -11,45 +11,67 @@ import AppAmbientBackground from './AppAmbientBackground';
import LockOverlay from './LockOverlay'; import LockOverlay from './LockOverlay';
import NotificationToaster from '@/components/notifications/NotificationToaster'; import NotificationToaster from '@/components/notifications/NotificationToaster';
export default function AppLayout() { function AppContent({ mobileOpen, setMobileOpen }: {
useTheme(); mobileOpen: boolean;
setMobileOpen: (v: boolean) => void;
}) {
const { isLocked, isLockResolved } = useLock();
const [collapsed, setCollapsed] = useState(() => { const [collapsed, setCollapsed] = useState(() => {
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); } try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
catch { return false; } catch { return false; }
}); });
// Don't render any content until we know the lock state
if (!isLockResolved || isLocked) {
return (
<>
<div className="h-dvh bg-background" />
{isLockResolved && <LockOverlay />}
</>
);
}
return (
<>
<div className="flex h-dvh overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden relative ambient-glass">
<AppAmbientBackground />
{/* Mobile header */}
<div className="relative z-10 flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
</div>
<main className="relative z-10 flex-1 overflow-y-auto mobile-scale">
<Outlet />
</main>
</div>
</div>
<NotificationToaster />
</>
);
}
export default function AppLayout() {
useTheme();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
return ( return (
<LockProvider> <LockProvider>
<AlertsProvider> <AlertsProvider>
<NotificationProvider> <NotificationProvider>
<div className="flex h-dvh overflow-hidden bg-background"> <AppContent mobileOpen={mobileOpen} setMobileOpen={setMobileOpen} />
<Sidebar
collapsed={collapsed}
onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden relative ambient-glass">
<AppAmbientBackground />
{/* Mobile header */}
<div className="relative z-10 flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
</div>
<main className="relative z-10 flex-1 overflow-y-auto mobile-scale">
<Outlet />
</main>
</div>
</div>
<LockOverlay />
<NotificationToaster />
</NotificationProvider> </NotificationProvider>
</AlertsProvider> </AlertsProvider>
</LockProvider> </LockProvider>

View File

@ -56,7 +56,7 @@ export default function LockOverlay() {
}; };
return ( return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background animate-fade-in"> <div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background">
<AmbientBackground /> <AmbientBackground />
<div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-sm px-4 animate-slide-up"> <div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-sm px-4 animate-slide-up">

View File

@ -14,7 +14,8 @@ import type { AuthStatus } from '@/types';
interface LockContextValue { interface LockContextValue {
isLocked: boolean; isLocked: boolean;
lock: () => void; isLockResolved: boolean;
lock: () => Promise<void>;
unlock: (password: string) => Promise<void>; unlock: (password: string) => Promise<void>;
} }
@ -32,6 +33,11 @@ export function LockProvider({ children }: { children: ReactNode }) {
return cached?.is_locked ?? false; return cached?.is_locked ?? false;
}); });
// Track whether lock state has been definitively resolved from the server
const [isLockResolved, setIsLockResolved] = useState(() => {
return queryClient.getQueryData<AuthStatus>(['auth']) !== undefined;
});
const { settings } = useSettings(); const { settings } = useSettings();
const activeMutations = useIsMutating(); const activeMutations = useIsMutating();
@ -40,31 +46,33 @@ export function LockProvider({ children }: { children: ReactNode }) {
const activeMutationsRef = useRef(activeMutations); const activeMutationsRef = useRef(activeMutations);
activeMutationsRef.current = activeMutations; activeMutationsRef.current = activeMutations;
// Sync lock state when auth status is fetched/refetched (e.g. on page refresh) // Subscribe to auth query updates to catch server lock state on refresh
useEffect(() => {
const cached = queryClient.getQueryData<AuthStatus>(['auth']);
if (cached?.is_locked && !isLocked) {
setIsLocked(true);
}
}, [queryClient, isLocked]);
// Subscribe to auth query updates to catch server lock state changes
useEffect(() => { useEffect(() => {
const unsubscribe = queryClient.getQueryCache().subscribe((event) => { const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if ( if (
event.type === 'updated' && event.type === 'updated' &&
event.action.type === 'success' && (event.action.type === 'success' || event.action.type === 'error') &&
event.query.queryKey[0] === 'auth' event.query.queryKey[0] === 'auth'
) { ) {
const data = event.query.state.data as AuthStatus | undefined; setIsLockResolved(true);
if (data?.is_locked) { if (event.action.type === 'success') {
setIsLocked(true); const data = event.query.state.data as AuthStatus | undefined;
if (data?.is_locked) {
setIsLocked(true);
}
} }
} }
}); });
return unsubscribe; return unsubscribe;
}, [queryClient]); }, [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 () => { const lock = useCallback(async () => {
setIsLocked(true); setIsLocked(true);
try { try {
@ -139,7 +147,7 @@ export function LockProvider({ children }: { children: ReactNode }) {
}, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]); }, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]);
return ( return (
<LockContext.Provider value={{ isLocked, lock, unlock }}> <LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}>
{children} {children}
</LockContext.Provider> </LockContext.Provider>
); );

View File

@ -15,22 +15,22 @@ const ACCENT_PRESETS: Record<string, { h: number; s: number; l: number }> = {
export function useTheme() { export function useTheme() {
const { settings } = useSettings(); 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(() => { 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]; const h = preset.h.toString();
if (preset) { const s = `${preset.s}%`;
const h = preset.h.toString(); const l = `${preset.l}%`;
const s = `${preset.s}%`; document.documentElement.style.setProperty('--accent-h', h);
const l = `${preset.l}%`; document.documentElement.style.setProperty('--accent-s', s);
document.documentElement.style.setProperty('--accent-h', h); document.documentElement.style.setProperty('--accent-l', l);
document.documentElement.style.setProperty('--accent-s', s); try {
document.documentElement.style.setProperty('--accent-l', l); localStorage.setItem('umbra-accent-color', JSON.stringify({ h, s, l }));
// Cache for next page load to prevent cyan flash } catch {}
try {
localStorage.setItem('umbra-accent-color', JSON.stringify({ h, s, l }));
} catch {}
}
}, [settings?.accent_color]); }, [settings?.accent_color]);
return { return {

View File

@ -20,6 +20,10 @@ api.interceptors.response.use(
window.location.href = '/login'; 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); return Promise.reject(error);
} }
); );