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