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..0e9c847 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -132,6 +132,19 @@ 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 + + # 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 @@ -542,6 +555,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 +571,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 +599,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 +637,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..7d8fbf6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,27 @@ UMBRA + + + 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..aae73f7 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -2,8 +2,9 @@ import { useState } from 'react'; import { Outlet } from 'react-router-dom'; import { Menu } from 'lucide-react'; import { useTheme } from '@/hooks/useTheme'; +import { usePrefetch } from '@/hooks/usePrefetch'; 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 +12,68 @@ 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(); + usePrefetch(isLockResolved && !isLocked); 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/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..cdb2e1e 100644 --- a/frontend/src/hooks/useLock.tsx +++ b/frontend/src/hooks/useLock.tsx @@ -7,13 +7,15 @@ 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; - lock: () => void; + isLockResolved: boolean; + lock: () => Promise; unlock: (password: string) => Promise; } @@ -23,7 +25,19 @@ 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; + }); + + // 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(); @@ -32,8 +46,40 @@ export function LockProvider({ children }: { children: ReactNode }) { const activeMutationsRef = useRef(activeMutations); activeMutationsRef.current = activeMutations; - const lock = useCallback(() => { + // 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 === 'error') && + event.query.queryKey[0] === 'auth' + ) { + 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 { + await api.post('/auth/lock'); + } catch { + // Lock locally even if server call fails — defense in depth + } }, []); const unlock = useCallback(async (password: string) => { @@ -41,8 +87,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(() => { @@ -97,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/usePrefetch.ts b/frontend/src/hooks/usePrefetch.ts new file mode 100644 index 0000000..db78bab --- /dev/null +++ b/frontend/src/hooks/usePrefetch.ts @@ -0,0 +1,72 @@ +import { useEffect, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import api from '@/lib/api'; +import { useSettings } from './useSettings'; + +/** + * Prefetches main page data in the background after the app unlocks. + * Ensures cache is warm before the user navigates to each tab, + * eliminating the skeleton flash on first visit. + */ +export function usePrefetch(enabled: boolean) { + const queryClient = useQueryClient(); + const { settings } = useSettings(); + const hasPrefetched = useRef(false); + const prevEnabled = useRef(false); + + // Reset on re-lock so subsequent unlocks refresh stale data (W-02) + useEffect(() => { + if (prevEnabled.current && !enabled) { + hasPrefetched.current = false; + } + prevEnabled.current = enabled; + }, [enabled]); + + useEffect(() => { + // Wait for settings to load so upcoming_days is accurate (S-05) + if (!enabled || hasPrefetched.current || !settings) return; + hasPrefetched.current = true; + + const now = new Date(); + const clientDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + const days = settings.upcoming_days || 7; + + // Prefetch all main page queries (no-op if already cached and fresh) + queryClient.prefetchQuery({ + queryKey: ['dashboard'], + queryFn: () => api.get(`/dashboard?client_date=${clientDate}`).then(r => r.data), + staleTime: 60_000, + }); + + queryClient.prefetchQuery({ + queryKey: ['upcoming', days], + queryFn: () => api.get(`/upcoming?days=${days}&client_date=${clientDate}`).then(r => r.data), + staleTime: 60_000, + }); + + queryClient.prefetchQuery({ + queryKey: ['todos'], + queryFn: () => api.get('/todos').then(r => r.data), + }); + + queryClient.prefetchQuery({ + queryKey: ['reminders'], + queryFn: () => api.get('/reminders').then(r => r.data), + }); + + queryClient.prefetchQuery({ + queryKey: ['projects'], + queryFn: () => api.get('/projects').then(r => r.data), + }); + + queryClient.prefetchQuery({ + queryKey: ['people'], + queryFn: () => api.get('/people').then(r => r.data), + }); + + queryClient.prefetchQuery({ + queryKey: ['locations'], + queryFn: () => api.get('/locations').then(r => r.data), + }); + }, [enabled, queryClient, settings]); +} diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts index 27d4f5d..59cf453 100644 --- a/frontend/src/hooks/useTheme.ts +++ b/frontend/src/hooks/useTheme.ts @@ -15,16 +15,33 @@ const ACCENT_PRESETS: Record = { export function useTheme() { const { settings } = useSettings(); + // Only apply accent color once settings have loaded from the API. + // Creates or updates a