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:
parent
89519a6dd3
commit
3d7166740e
@ -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
|
||||
|
||||
|
||||
|
||||
@ -20,11 +20,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authStatus, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-dvh items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="h-dvh bg-background" />;
|
||||
}
|
||||
|
||||
if (!authStatus?.authenticated) {
|
||||
@ -38,11 +34,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { authStatus, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-dvh items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="h-dvh bg-background" />;
|
||||
}
|
||||
|
||||
if (!authStatus?.authenticated || authStatus?.role !== 'admin') {
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
<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);
|
||||
|
||||
return (
|
||||
<LockProvider>
|
||||
<AlertsProvider>
|
||||
<NotificationProvider>
|
||||
<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>
|
||||
<LockOverlay />
|
||||
<NotificationToaster />
|
||||
<AppContent mobileOpen={mobileOpen} setMobileOpen={setMobileOpen} />
|
||||
</NotificationProvider>
|
||||
</AlertsProvider>
|
||||
</LockProvider>
|
||||
|
||||
@ -56,7 +56,7 @@ export default function LockOverlay() {
|
||||
};
|
||||
|
||||
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 />
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-sm px-4 animate-slide-up">
|
||||
|
||||
@ -14,7 +14,8 @@ import type { AuthStatus } from '@/types';
|
||||
|
||||
interface LockContextValue {
|
||||
isLocked: boolean;
|
||||
lock: () => void;
|
||||
isLockResolved: boolean;
|
||||
lock: () => Promise<void>;
|
||||
unlock: (password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@ -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<AuthStatus>(['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<AuthStatus>(['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 (
|
||||
<LockContext.Provider value={{ isLocked, lock, unlock }}>
|
||||
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}>
|
||||
{children}
|
||||
</LockContext.Provider>
|
||||
);
|
||||
|
||||
@ -15,22 +15,22 @@ const ACCENT_PRESETS: Record<string, { h: number; s: number; l: number }> = {
|
||||
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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user