From 89519a6dd36a04694abd219fd3294c5cfad59ded Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 12 Mar 2026 19:00:55 +0800 Subject: [PATCH 01/11] Fix lock screen bypass, theme flicker, skeleton flash, and sidebar click target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: Lock state was purely React useState — refreshing the page reset it. Now persisted server-side via is_locked/locked_at columns on user_sessions. POST /auth/lock sets the flag, /auth/verify-password clears it, and GET /auth/status returns is_locked so the frontend initializes correctly. UI: Cache accent color in localStorage and apply via inline script in index.html before React hydrates to eliminate the cyan flash on load. UI: Increase TanStack Query gcTime from 5min to 30min so page data survives component unmount/remount across tab switches without skeleton. UI: Move Projects nav onClick from the icon element to the full-width container div so the entire row is clickable when the sidebar is collapsed. Co-Authored-By: Claude Opus 4.6 --- .../versions/052_add_session_lock_columns.py | 18 +++++++ backend/app/models/session.py | 4 ++ backend/app/routers/auth.py | 32 +++++++++++- frontend/index.html | 15 ++++++ frontend/src/components/layout/Sidebar.tsx | 22 +++----- frontend/src/hooks/useLock.tsx | 50 +++++++++++++++++-- frontend/src/hooks/useTheme.ts | 13 +++-- frontend/src/main.tsx | 1 + frontend/src/types/index.ts | 1 + 9 files changed, 132 insertions(+), 24 deletions(-) create mode 100644 backend/alembic/versions/052_add_session_lock_columns.py diff --git a/backend/alembic/versions/052_add_session_lock_columns.py b/backend/alembic/versions/052_add_session_lock_columns.py new file mode 100644 index 0000000..15bc2a1 --- /dev/null +++ b/backend/alembic/versions/052_add_session_lock_columns.py @@ -0,0 +1,18 @@ +"""add is_locked and locked_at to user_sessions + +Revision ID: 052 +Revises: 051 +""" +from alembic import op +import sqlalchemy as sa + +revision = "052" +down_revision = "051" + +def upgrade(): + op.add_column("user_sessions", sa.Column("is_locked", sa.Boolean(), server_default="false", nullable=False)) + op.add_column("user_sessions", sa.Column("locked_at", sa.DateTime(), nullable=True)) + +def downgrade(): + op.drop_column("user_sessions", "locked_at") + op.drop_column("user_sessions", "is_locked") diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 33a2a21..860b286 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -18,6 +18,10 @@ class UserSession(Base): expires_at: Mapped[datetime] = mapped_column(nullable=False) revoked: Mapped[bool] = mapped_column(Boolean, default=False) + # Session lock — persists across page refresh + is_locked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + locked_at: Mapped[datetime | None] = mapped_column(nullable=True) + # Audit fields for security logging ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index c998f32..e944aed 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -132,6 +132,9 @@ async def get_current_user( fresh_token = create_session_token(user_id, session_id) _set_session_cookie(response, fresh_token) + # Stash session on request so lock/unlock endpoints can access it + request.state.db_session = db_session + return user @@ -542,6 +545,8 @@ async def auth_status( authenticated = False role = None + is_locked = False + if not setup_required and session_cookie: payload = verify_session_token(session_cookie) if payload: @@ -556,8 +561,10 @@ async def auth_status( UserSession.expires_at > datetime.now(), ) ) - if session_result.scalar_one_or_none() is not None: + db_sess = session_result.scalar_one_or_none() + if db_sess is not None: authenticated = True + is_locked = db_sess.is_locked user_obj_result = await db.execute( select(User).where(User.id == user_id, User.is_active == True) ) @@ -582,12 +589,28 @@ async def auth_status( "role": role, "username": u.username if authenticated and u else None, "registration_open": registration_open, + "is_locked": is_locked, } +@router.post("/lock") +async def lock_session( + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Mark the current session as locked. Frontend must verify password to unlock.""" + db_session: UserSession = request.state.db_session + db_session.is_locked = True + db_session.locked_at = datetime.now() + await db.commit() + return {"locked": True} + + @router.post("/verify-password") async def verify_password( data: VerifyPasswordRequest, + request: Request, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): @@ -604,7 +627,12 @@ async def verify_password( if new_hash: current_user.password_hash = new_hash - await db.commit() + + # Clear session lock on successful password verification + db_session: UserSession = request.state.db_session + db_session.is_locked = False + db_session.locked_at = None + await db.commit() return {"verified": True} diff --git a/frontend/index.html b/frontend/index.html index 2da949e..52bf6d2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,21 @@ UMBRA + diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index f41b619..54fb7ee 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -83,28 +83,20 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
{ + navigate('/projects'); + if (mobileOpen) onMobileClose(); + }} > - { - navigate('/projects'); - if (mobileOpen) onMobileClose(); - }} - /> + {showExpanded && ( <> - { - navigate('/projects'); - if (mobileOpen) onMobileClose(); - }} - > + Projects {trackedProjects && trackedProjects.length > 0 && ( diff --git a/frontend/src/hooks/useLock.tsx b/frontend/src/hooks/useLock.tsx index fed762d..d20ce11 100644 --- a/frontend/src/hooks/useLock.tsx +++ b/frontend/src/hooks/useLock.tsx @@ -7,9 +7,10 @@ import { useRef, type ReactNode, } from 'react'; -import { useIsMutating } from '@tanstack/react-query'; +import { useIsMutating, useQueryClient } from '@tanstack/react-query'; import { useSettings } from '@/hooks/useSettings'; import api from '@/lib/api'; +import type { AuthStatus } from '@/types'; interface LockContextValue { isLocked: boolean; @@ -23,7 +24,14 @@ const ACTIVITY_EVENTS = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart' const THROTTLE_MS = 5_000; // only reset timer every 5s of activity export function LockProvider({ children }: { children: ReactNode }) { - const [isLocked, setIsLocked] = useState(false); + const queryClient = useQueryClient(); + + // Initialize lock state from the auth status cache (server-persisted) + const [isLocked, setIsLocked] = useState(() => { + const cached = queryClient.getQueryData(['auth']); + return cached?.is_locked ?? false; + }); + const { settings } = useSettings(); const activeMutations = useIsMutating(); @@ -32,8 +40,38 @@ export function LockProvider({ children }: { children: ReactNode }) { const activeMutationsRef = useRef(activeMutations); activeMutationsRef.current = activeMutations; - const lock = useCallback(() => { + // 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 + useEffect(() => { + const unsubscribe = queryClient.getQueryCache().subscribe((event) => { + if ( + event.type === 'updated' && + event.action.type === 'success' && + event.query.queryKey[0] === 'auth' + ) { + const data = event.query.state.data as AuthStatus | undefined; + if (data?.is_locked) { + setIsLocked(true); + } + } + }); + return unsubscribe; + }, [queryClient]); + + const lock = useCallback(async () => { setIsLocked(true); + try { + await api.post('/auth/lock'); + } catch { + // Lock locally even if server call fails — defense in depth + } }, []); const unlock = useCallback(async (password: string) => { @@ -41,8 +79,12 @@ export function LockProvider({ children }: { children: ReactNode }) { if (data.verified) { setIsLocked(false); lastActivityRef.current = Date.now(); + // Update auth cache to reflect unlocked state + queryClient.setQueryData(['auth'], (old) => + old ? { ...old, is_locked: false } : old + ); } - }, []); + }, [queryClient]); // Auto-lock idle timer useEffect(() => { diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts index 27d4f5d..afecf67 100644 --- a/frontend/src/hooks/useTheme.ts +++ b/frontend/src/hooks/useTheme.ts @@ -20,9 +20,16 @@ export function useTheme() { const preset = ACCENT_PRESETS[settings.accent_color]; if (preset) { - document.documentElement.style.setProperty('--accent-h', preset.h.toString()); - document.documentElement.style.setProperty('--accent-s', `${preset.s}%`); - document.documentElement.style.setProperty('--accent-l', `${preset.l}%`); + 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 {} } }, [settings?.accent_color]); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 61ca364..b23d8fd 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -20,6 +20,7 @@ const queryClient = new QueryClient({ refetchOnWindowFocus: false, retry: 1, staleTime: 5 * 60 * 1000, + gcTime: 30 * 60 * 1000, // keep cache 30 min to avoid skeleton flash on tab switch }, }, }); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 5e7339f..420507b 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -221,6 +221,7 @@ export interface AuthStatus { role: UserRole | null; username: string | null; registration_open: boolean; + is_locked: boolean; } // Login response discriminated union From 3d7166740ed46313e4f370cd4465d117b84766c0 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 12 Mar 2026 19:56:05 +0800 Subject: [PATCH 02/11] 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); } ); From 988dc37b64c18214278e450cf15e3e8399cc0bdf Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 12 Mar 2026 20:02:13 +0800 Subject: [PATCH 03/11] Fix accent color flash on refresh by eliminating CSS/JS race Guard useTheme effect to skip when settings are undefined, preventing it from overwriting the inline script's cached color with cyan defaults. Move CSS accent var defaults from index.css :root into the index.html inline script so they are always set synchronously before paint. Co-Authored-By: Claude Opus 4.6 --- frontend/index.html | 10 ++++++++-- frontend/src/hooks/useTheme.ts | 11 +++++++---- frontend/src/index.css | 7 +++---- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 52bf6d2..b5c9d6f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,18 +9,24 @@ UMBRA diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts index 250075b..e3445ce 100644 --- a/frontend/src/hooks/useTheme.ts +++ b/frontend/src/hooks/useTheme.ts @@ -15,10 +15,13 @@ 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 + // Only apply accent color once settings have loaded from the API. + // The inline script in index.html handles the initial paint from localStorage cache. + // Firing this effect with settings=undefined would overwrite the cache with cyan. useEffect(() => { - const colorName = settings?.accent_color || 'cyan'; + if (!settings) return; + + const colorName = settings.accent_color || 'cyan'; const preset = ACCENT_PRESETS[colorName]; if (!preset) return; @@ -31,7 +34,7 @@ export function useTheme() { try { localStorage.setItem('umbra-accent-color', JSON.stringify({ h, s, l })); } catch {} - }, [settings?.accent_color]); + }, [settings]); return { accentColor: settings?.accent_color || 'cyan', diff --git a/frontend/src/index.css b/frontend/src/index.css index 83ce23f..6b58c1a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -26,10 +26,9 @@ --ring: var(--accent-h) var(--accent-s) var(--accent-l); --radius: 0.5rem; - /* Default accent: cyan */ - --accent-h: 187; - --accent-s: 85.7%; - --accent-l: 53.3%; + /* Accent vars are set by the inline script in index.html (from localStorage + cache or cyan fallback). No CSS defaults here — they would race with the + inline script and cause a flash of wrong color on refresh. */ /* Transitions */ --transition-fast: 150ms; From e7be762198baf2f5bfeff960e3eb01bdfe787172 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 12 Mar 2026 20:10:51 +0800 Subject: [PATCH 04/11] Fix accent color loss on refresh by using injected style tag The inline script's style.setProperty values on were being stripped during Vite's CSS injection. Switch to injecting a From 18a2c1314a1255d0250a8f66f43dce83db7feb4f Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 12 Mar 2026 21:15:47 +0800 Subject: [PATCH 09/11] Remove @layer base cyan defaults to eliminate refresh flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser paints cached Vite CSS (@layer base cyan defaults) before the inline script populates the static style tag. Remove the competing cyan defaults — accent vars now come exclusively from the static