diff --git a/backend/alembic/versions/025_add_auto_lock_settings.py b/backend/alembic/versions/025_add_auto_lock_settings.py
new file mode 100644
index 0000000..a50992c
--- /dev/null
+++ b/backend/alembic/versions/025_add_auto_lock_settings.py
@@ -0,0 +1,30 @@
+"""Add auto-lock settings columns to settings table.
+
+Revision ID: 025
+Revises: 024
+Create Date: 2026-02-25
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers
+revision = "025"
+down_revision = "024"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "settings",
+ sa.Column("auto_lock_enabled", sa.Boolean(), server_default="false", nullable=False),
+ )
+ op.add_column(
+ "settings",
+ sa.Column("auto_lock_minutes", sa.Integer(), server_default="5", nullable=False),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("settings", "auto_lock_minutes")
+ op.drop_column("settings", "auto_lock_enabled")
diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py
index 6552d7c..cf5ea87 100644
--- a/backend/app/models/settings.py
+++ b/backend/app/models/settings.py
@@ -42,6 +42,10 @@ class Settings(Base):
ntfy_todo_lead_days: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
ntfy_project_lead_days: Mapped[int] = mapped_column(Integer, default=2, server_default="2")
+ # Auto-lock settings
+ auto_lock_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
+ auto_lock_minutes: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
+
@property
def ntfy_has_token(self) -> bool:
"""Derived field for SettingsResponse — True when an auth token is stored."""
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 854e5bf..8515fa6 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -28,7 +28,7 @@ from app.database import get_db
from app.models.user import User
from app.models.session import UserSession
from app.models.settings import Settings
-from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest
+from app.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest
from app.services.auth import (
hash_password,
verify_password_with_upgrade,
@@ -373,6 +373,29 @@ async def auth_status(
return {"authenticated": authenticated, "setup_required": setup_required}
+@router.post("/verify-password")
+async def verify_password(
+ data: VerifyPasswordRequest,
+ db: AsyncSession = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """
+ Verify the current user's password without changing anything.
+ Used by the frontend lock screen to re-authenticate without a full login.
+ Also handles transparent bcrypt→Argon2id upgrade.
+ """
+ valid, new_hash = verify_password_with_upgrade(data.password, current_user.password_hash)
+ if not valid:
+ raise HTTPException(status_code=401, detail="Invalid password")
+
+ # Persist upgraded hash if migration happened
+ if new_hash:
+ current_user.password_hash = new_hash
+ await db.commit()
+
+ return {"verified": True}
+
+
@router.post("/change-password")
async def change_password(
data: ChangePasswordRequest,
diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py
index 50d0959..6ac79a3 100644
--- a/backend/app/routers/settings.py
+++ b/backend/app/routers/settings.py
@@ -36,6 +36,8 @@ def _to_settings_response(s: Settings) -> SettingsResponse:
ntfy_todo_lead_days=s.ntfy_todo_lead_days,
ntfy_project_lead_days=s.ntfy_project_lead_days,
ntfy_has_token=bool(s.ntfy_auth_token), # derived — never expose the token value
+ auto_lock_enabled=s.auto_lock_enabled,
+ auto_lock_minutes=s.auto_lock_minutes,
created_at=s.created_at,
updated_at=s.updated_at,
)
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index eb09b0d..198eb89 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -60,3 +60,7 @@ class ChangePasswordRequest(BaseModel):
@classmethod
def validate_new_password(cls, v: str) -> str:
return _validate_password_strength(v)
+
+
+class VerifyPasswordRequest(BaseModel):
+ password: str
diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py
index ab32bee..8591e62 100644
--- a/backend/app/schemas/settings.py
+++ b/backend/app/schemas/settings.py
@@ -31,6 +31,17 @@ class SettingsUpdate(BaseModel):
ntfy_todo_lead_days: Optional[int] = None
ntfy_project_lead_days: Optional[int] = None
+ # Auto-lock settings
+ auto_lock_enabled: Optional[bool] = None
+ auto_lock_minutes: Optional[int] = None
+
+ @field_validator('auto_lock_minutes')
+ @classmethod
+ def validate_auto_lock_minutes(cls, v: Optional[int]) -> Optional[int]:
+ if v is not None and not (1 <= v <= 60):
+ raise ValueError("auto_lock_minutes must be between 1 and 60")
+ return v
+
@field_validator('first_day_of_week')
@classmethod
def validate_first_day(cls, v: int | None) -> int | None:
@@ -134,6 +145,10 @@ class SettingsResponse(BaseModel):
# Derived field: computed via Settings.ntfy_has_token property (from_attributes reads it)
ntfy_has_token: bool = False
+ # Auto-lock settings
+ auto_lock_enabled: bool = False
+ auto_lock_minutes: int = 5
+
created_at: datetime
updated_at: datetime
diff --git a/frontend/src/components/auth/AmbientBackground.tsx b/frontend/src/components/auth/AmbientBackground.tsx
new file mode 100644
index 0000000..d169c80
--- /dev/null
+++ b/frontend/src/components/auth/AmbientBackground.tsx
@@ -0,0 +1,45 @@
+/**
+ * Shared animated ambient background for the login screen and lock overlay.
+ * Renders floating gradient orbs and a subtle grid overlay.
+ */
+export default function AmbientBackground() {
+ return (
+
+ {/* Animated gradient orbs */}
+
+
+
+
+ {/* Subtle grid overlay */}
+
+
+ );
+}
diff --git a/frontend/src/components/auth/LockScreen.tsx b/frontend/src/components/auth/LockScreen.tsx
index 57895bd..7320f3a 100644
--- a/frontend/src/components/auth/LockScreen.tsx
+++ b/frontend/src/components/auth/LockScreen.tsx
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
+import AmbientBackground from './AmbientBackground';
/** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */
function validatePassword(password: string): string | null {
@@ -92,20 +93,10 @@ export default function LockScreen() {
return (
- {/* Ambient glow blobs */}
-
+
{/* Wordmark — in flex flow above card */}
-
+
UMBRA
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
index 4b9df6a..c300cac 100644
--- a/frontend/src/components/layout/AppLayout.tsx
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -3,8 +3,10 @@ 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 { Button } from '@/components/ui/button';
import Sidebar from './Sidebar';
+import LockOverlay from './LockOverlay';
export default function AppLayout() {
useTheme();
@@ -13,26 +15,29 @@ export default function AppLayout() {
return (
-
-
setCollapsed(!collapsed)}
- mobileOpen={mobileOpen}
- onMobileClose={() => setMobileOpen(false)}
- />
-
- {/* Mobile header */}
-
-
-
UMBRA
+
+
+
setCollapsed(!collapsed)}
+ mobileOpen={mobileOpen}
+ onMobileClose={() => setMobileOpen(false)}
+ />
+
+ {/* Mobile header */}
+
+
+
UMBRA
+
+
+
+
-
-
-
-
+
+
);
}
diff --git a/frontend/src/components/layout/LockOverlay.tsx b/frontend/src/components/layout/LockOverlay.tsx
new file mode 100644
index 0000000..cec2cd6
--- /dev/null
+++ b/frontend/src/components/layout/LockOverlay.tsx
@@ -0,0 +1,112 @@
+import { useState, FormEvent, useEffect, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { toast } from 'sonner';
+import { Lock, Loader2 } from 'lucide-react';
+import { useLock } from '@/hooks/useLock';
+import { useAuth } from '@/hooks/useAuth';
+import { useSettings } from '@/hooks/useSettings';
+import { getErrorMessage } from '@/lib/api';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import AmbientBackground from '@/components/auth/AmbientBackground';
+
+export default function LockOverlay() {
+ const { isLocked, unlock } = useLock();
+ const { logout } = useAuth();
+ const { settings } = useSettings();
+ const navigate = useNavigate();
+
+ const [password, setPassword] = useState('');
+ const [isUnlocking, setIsUnlocking] = useState(false);
+ const inputRef = useRef
(null);
+
+ // Focus password input when lock activates
+ useEffect(() => {
+ if (isLocked) {
+ setPassword('');
+ // Small delay to let the overlay render
+ const t = setTimeout(() => inputRef.current?.focus(), 100);
+ return () => clearTimeout(t);
+ }
+ }, [isLocked]);
+
+ if (!isLocked) return null;
+
+ const preferredName = settings?.preferred_name;
+
+ const handleUnlock = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!password.trim()) return;
+
+ setIsUnlocking(true);
+ try {
+ await unlock(password);
+ } catch (error) {
+ toast.error(getErrorMessage(error, 'Invalid password'));
+ setPassword('');
+ inputRef.current?.focus();
+ } finally {
+ setIsUnlocking(false);
+ }
+ };
+
+ const handleSwitchAccount = async () => {
+ await logout();
+ navigate('/login');
+ };
+
+ return (
+
+
+
+
+ {/* Lock icon */}
+
+
+
+
+ {/* Greeting */}
+
+
Locked
+ {preferredName && (
+
+ Welcome back, {preferredName}
+
+ )}
+
+
+ {/* Password form */}
+
+
+ {/* Switch account link */}
+
+
+
+ );
+}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx
index 9589d67..50d0e51 100644
--- a/frontend/src/components/layout/Sidebar.tsx
+++ b/frontend/src/components/layout/Sidebar.tsx
@@ -15,9 +15,11 @@ import {
ChevronDown,
X,
LogOut,
+ Lock,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
+import { useLock } from '@/hooks/useLock';
import { Button } from '@/components/ui/button';
import api from '@/lib/api';
import type { Project } from '@/types';
@@ -42,6 +44,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
const navigate = useNavigate();
const location = useLocation();
const { logout } = useAuth();
+ const { lock } = useLock();
const [projectsExpanded, setProjectsExpanded] = useState(false);
const { data: trackedProjects } = useQuery({
@@ -180,6 +183,13 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
+
(null);
const debounceRef = useRef>();
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
+ const [autoLockEnabled, setAutoLockEnabled] = useState(settings?.auto_lock_enabled ?? false);
+ const [autoLockMinutes, setAutoLockMinutes] = useState(settings?.auto_lock_minutes ?? 5);
// Sync state when settings load
useEffect(() => {
@@ -56,6 +60,8 @@ export default function SettingsPage() {
setUpcomingDays(settings.upcoming_days);
setPreferredName(settings.preferred_name ?? '');
setFirstDayOfWeek(settings.first_day_of_week);
+ setAutoLockEnabled(settings.auto_lock_enabled);
+ setAutoLockMinutes(settings.auto_lock_minutes);
}
}, [settings?.id]); // only re-sync on initial load (settings.id won't change)
@@ -176,6 +182,31 @@ export default function SettingsPage() {
}
};
+ const handleAutoLockToggle = async (checked: boolean) => {
+ const previous = autoLockEnabled;
+ setAutoLockEnabled(checked);
+ try {
+ await updateSettings({ auto_lock_enabled: checked });
+ toast.success(checked ? 'Auto-lock enabled' : 'Auto-lock disabled');
+ } catch {
+ setAutoLockEnabled(previous);
+ toast.error('Failed to update auto-lock setting');
+ }
+ };
+
+ const handleAutoLockMinutesSave = async () => {
+ const clamped = Math.max(1, Math.min(60, autoLockMinutes || 5));
+ setAutoLockMinutes(clamped);
+ if (clamped === settings?.auto_lock_minutes) return;
+ try {
+ await updateSettings({ auto_lock_minutes: clamped });
+ toast.success(`Auto-lock timeout set to ${clamped} minutes`);
+ } catch {
+ setAutoLockMinutes(settings?.auto_lock_minutes ?? 5);
+ toast.error('Failed to update auto-lock timeout');
+ }
+ };
+
return (
{/* Page header — matches Stage 4-5 pages */}
@@ -349,9 +380,56 @@ export default function SettingsPage() {
- {/* ── Right column: Authentication, Calendar, Dashboard, Integrations ── */}
+ {/* ── Right column: Security, Authentication, Calendar, Dashboard, Integrations ── */}
+ {/* Security (auto-lock) */}
+
+
+
+
+
+
+
+ Security
+ Configure screen lock behavior
+
+
+
+
+
+
+
+
+ Automatically lock the screen after idle time
+
+
+
+
+
+
+
+ setAutoLockMinutes(parseInt(e.target.value) || 5)}
+ onBlur={handleAutoLockMinutesSave}
+ onKeyDown={(e) => { if (e.key === 'Enter') handleAutoLockMinutesSave(); }}
+ className="w-24"
+ disabled={!autoLockEnabled || isUpdating}
+ />
+ minutes
+
+
+
+
+
{/* Authentication (TOTP + password change) */}
diff --git a/frontend/src/hooks/useLock.tsx b/frontend/src/hooks/useLock.tsx
new file mode 100644
index 0000000..4dc01f4
--- /dev/null
+++ b/frontend/src/hooks/useLock.tsx
@@ -0,0 +1,108 @@
+import {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type ReactNode,
+} from 'react';
+import { useIsMutating } from '@tanstack/react-query';
+import { useSettings } from '@/hooks/useSettings';
+import api from '@/lib/api';
+
+interface LockContextValue {
+ isLocked: boolean;
+ lock: () => void;
+ unlock: (password: string) => Promise
;
+}
+
+const LockContext = createContext(null);
+
+const ACTIVITY_EVENTS = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart'] as const;
+const THROTTLE_MS = 5_000; // only reset timer every 5s of activity
+
+export function LockProvider({ children }: { children: ReactNode }) {
+ const [isLocked, setIsLocked] = useState(false);
+ const { settings } = useSettings();
+ const activeMutations = useIsMutating();
+
+ const timerRef = useRef | null>(null);
+ const lastActivityRef = useRef(Date.now());
+
+ const lock = useCallback(() => {
+ setIsLocked(true);
+ }, []);
+
+ const unlock = useCallback(async (password: string) => {
+ const { data } = await api.post<{ verified: boolean }>('/auth/verify-password', { password });
+ if (data.verified) {
+ setIsLocked(false);
+ lastActivityRef.current = Date.now();
+ }
+ }, []);
+
+ // Auto-lock idle timer
+ useEffect(() => {
+ const enabled = settings?.auto_lock_enabled ?? false;
+ const minutes = settings?.auto_lock_minutes ?? 5;
+
+ if (!enabled || isLocked) {
+ // Clear any existing timer when disabled or already locked
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ return;
+ }
+
+ const timeoutMs = minutes * 60_000;
+
+ const resetTimer = () => {
+ // Don't lock while TanStack mutations are in flight
+ if (activeMutations > 0) return;
+
+ if (timerRef.current) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
+ lock();
+ }, timeoutMs);
+ };
+
+ const handleActivity = () => {
+ const now = Date.now();
+ if (now - lastActivityRef.current < THROTTLE_MS) return;
+ lastActivityRef.current = now;
+ resetTimer();
+ };
+
+ // Start the initial timer
+ resetTimer();
+
+ // Attach throttled listeners
+ for (const event of ACTIVITY_EVENTS) {
+ document.addEventListener(event, handleActivity, { passive: true });
+ }
+
+ return () => {
+ for (const event of ACTIVITY_EVENTS) {
+ document.removeEventListener(event, handleActivity);
+ }
+ if (timerRef.current) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ };
+ }, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock, activeMutations]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useLock(): LockContextValue {
+ const ctx = useContext(LockContext);
+ if (!ctx) throw new Error('useLock must be used within a LockProvider');
+ return ctx;
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 0d599e2..508f5b1 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -192,3 +192,47 @@
color: hsl(var(--accent-color));
font-weight: 600;
}
+
+/* ── Ambient background animations ── */
+
+@keyframes drift-1 {
+ 0%, 100% { transform: translate(0, 0); }
+ 25% { transform: translate(60px, 40px); }
+ 50% { transform: translate(-30px, 80px); }
+ 75% { transform: translate(40px, -20px); }
+}
+
+@keyframes drift-2 {
+ 0%, 100% { transform: translate(0, 0); }
+ 25% { transform: translate(-50px, -30px); }
+ 50% { transform: translate(40px, -60px); }
+ 75% { transform: translate(-20px, 40px); }
+}
+
+@keyframes drift-3 {
+ 0%, 100% { transform: translate(0, 0); }
+ 33% { transform: translate(30px, -50px); }
+ 66% { transform: translate(-40px, 30px); }
+}
+
+@keyframes slide-up {
+ from {
+ opacity: 0;
+ transform: translateY(16px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes fade-in {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.animate-drift-1 { animation: drift-1 25s ease-in-out infinite; }
+.animate-drift-2 { animation: drift-2 30s ease-in-out infinite; }
+.animate-drift-3 { animation: drift-3 20s ease-in-out infinite; }
+.animate-slide-up { animation: slide-up 0.5s ease-out both; }
+.animate-fade-in { animation: fade-in 0.3s ease-out both; }
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 7ec14b2..b47ec34 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -19,6 +19,9 @@ export interface Settings {
ntfy_todo_lead_days: number;
ntfy_project_lead_days: number;
ntfy_has_token: boolean;
+ // Auto-lock settings
+ auto_lock_enabled: boolean;
+ auto_lock_minutes: number;
created_at: string;
updated_at: string;
}