From 89519a6dd36a04694abd219fd3294c5cfad59ded Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Thu, 12 Mar 2026 19:00:55 +0800 Subject: [PATCH] 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