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 (
-
- );
+ return ;
}
if (!authStatus?.authenticated) {
@@ -38,11 +34,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth();
if (isLoading) {
- return (
-
- );
+ 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