Add lock screen, auto-lock timeout, and login visual upgrade

- Backend: POST /verify-password endpoint for lock screen re-auth,
  auto_lock_enabled/auto_lock_minutes columns on Settings with migration 025
- Frontend: LockProvider context with idle detection (throttled activity
  listeners, pauses during mutations), Lock button in sidebar, full-screen
  LockOverlay with password re-entry and "Switch account" option
- Settings: Security card with auto-lock toggle and configurable timeout (1-60 min)
- Visual: Upgraded login screen with large title, animated floating gradient
  orbs (3 drift keyframes), subtle grid overlay, shared AmbientBackground component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-25 10:03:12 +08:00
parent e5b6725081
commit b0af07c270
15 changed files with 506 additions and 32 deletions

View File

@ -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")

View File

@ -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."""

View File

@ -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 bcryptArgon2id 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,

View File

@ -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,
)

View File

@ -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

View File

@ -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

View File

@ -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 (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
{/* Animated gradient orbs */}
<div
className="absolute h-[500px] w-[500px] rounded-full opacity-15 blur-[100px] animate-drift-1"
style={{
background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)',
top: '-10%',
left: '-10%',
}}
/>
<div
className="absolute h-[400px] w-[400px] rounded-full opacity-10 blur-[100px] animate-drift-2"
style={{
background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)',
bottom: '-5%',
right: '-5%',
}}
/>
<div
className="absolute h-[350px] w-[350px] rounded-full opacity-[0.07] blur-[80px] animate-drift-3"
style={{
background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)',
top: '40%',
right: '20%',
}}
/>
{/* Subtle grid overlay */}
<div
className="absolute inset-0 opacity-[0.035]"
style={{
backgroundImage:
'linear-gradient(rgba(255,255,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.5) 1px, transparent 1px)',
backgroundSize: '60px 60px',
}}
/>
</div>
);
}

View File

@ -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 (
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background p-4 overflow-hidden">
{/* Ambient glow blobs */}
<div className="pointer-events-none absolute inset-0" aria-hidden="true">
<div
className="absolute -top-32 -left-32 h-96 w-96 rounded-full opacity-20 blur-3xl"
style={{ background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)' }}
/>
<div
className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full opacity-10 blur-3xl"
style={{ background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)' }}
/>
</div>
<AmbientBackground />
{/* Wordmark — in flex flow above card */}
<span className="font-heading text-2xl font-bold tracking-tight text-accent mb-6 relative z-10">
<span className="font-heading text-5xl sm:text-6xl font-bold tracking-tight text-accent mb-10 relative z-10 animate-slide-up">
UMBRA
</span>

View File

@ -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 (
<AlertsProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile header */}
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
<LockProvider>
<div className="flex h-screen overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => setCollapsed(!collapsed)}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Mobile header */}
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
</div>
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
</div>
</div>
<LockOverlay />
</LockProvider>
</AlertsProvider>
);
}

View File

@ -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<HTMLInputElement>(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 (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-background animate-fade-in">
<AmbientBackground />
<div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-sm px-4 animate-slide-up">
{/* Lock icon */}
<div className="p-3 rounded-full bg-accent/10 border border-accent/20">
<Lock className="h-6 w-6 text-accent" />
</div>
{/* Greeting */}
<div className="text-center space-y-1">
<h1 className="text-2xl font-heading font-semibold text-foreground">Locked</h1>
{preferredName && (
<p className="text-sm text-muted-foreground">
Welcome back, {preferredName}
</p>
)}
</div>
{/* Password form */}
<form onSubmit={handleUnlock} className="w-full space-y-4">
<Input
ref={inputRef}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password to unlock"
autoComplete="current-password"
className="text-center"
/>
<Button type="submit" className="w-full" disabled={isUnlocking}>
{isUnlocking ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Unlocking
</>
) : (
'Unlock'
)}
</Button>
</form>
{/* Switch account link */}
<button
type="button"
onClick={handleSwitchAccount}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Switch account
</button>
</div>
</div>
);
}

View File

@ -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
</nav>
<div className="border-t p-2 space-y-1">
<button
onClick={lock}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent/10 hover:text-accent border-l-2 border-transparent"
>
<Lock className="h-5 w-5 shrink-0" />
{showExpanded && <span>Lock</span>}
</button>
<NavLink
to="/settings"
onClick={mobileOpen ? onMobileClose : undefined}

View File

@ -12,6 +12,7 @@ import {
X,
Search,
Loader2,
Shield,
} from 'lucide-react';
import { useSettings } from '@/hooks/useSettings';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@ -20,6 +21,7 @@ import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
import api from '@/lib/api';
import type { GeoLocation } from '@/types';
import { Switch } from '@/components/ui/switch';
import TotpSetupSection from './TotpSetupSection';
import NtfySettingsSection from './NtfySettingsSection';
@ -48,6 +50,8 @@ export default function SettingsPage() {
const searchRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
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 (
<div className="flex flex-col h-full">
{/* Page header — matches Stage 4-5 pages */}
@ -349,9 +380,56 @@ export default function SettingsPage() {
</div>
{/* ── Right column: Authentication, Calendar, Dashboard, Integrations ── */}
{/* ── Right column: Security, Authentication, Calendar, Dashboard, Integrations ── */}
<div className="space-y-6">
{/* Security (auto-lock) */}
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-md bg-emerald-500/10">
<Shield className="h-4 w-4 text-emerald-400" aria-hidden="true" />
</div>
<div>
<CardTitle>Security</CardTitle>
<CardDescription>Configure screen lock behavior</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Auto-lock</Label>
<p className="text-sm text-muted-foreground">
Automatically lock the screen after idle time
</p>
</div>
<Switch
checked={autoLockEnabled}
onCheckedChange={handleAutoLockToggle}
/>
</div>
<div className="space-y-2">
<Label htmlFor="auto_lock_minutes">Lock after</Label>
<div className="flex gap-3 items-center">
<Input
id="auto_lock_minutes"
type="number"
min="1"
max="60"
value={autoLockMinutes}
onChange={(e) => setAutoLockMinutes(parseInt(e.target.value) || 5)}
onBlur={handleAutoLockMinutesSave}
onKeyDown={(e) => { if (e.key === 'Enter') handleAutoLockMinutesSave(); }}
className="w-24"
disabled={!autoLockEnabled || isUpdating}
/>
<span className="text-sm text-muted-foreground">minutes</span>
</div>
</div>
</CardContent>
</Card>
{/* Authentication (TOTP + password change) */}
<TotpSetupSection />

View File

@ -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<void>;
}
const LockContext = createContext<LockContextValue | null>(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<ReturnType<typeof setTimeout> | null>(null);
const lastActivityRef = useRef<number>(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 (
<LockContext.Provider value={{ isLocked, lock, unlock }}>
{children}
</LockContext.Provider>
);
}
export function useLock(): LockContextValue {
const ctx = useContext(LockContext);
if (!ctx) throw new Error('useLock must be used within a LockProvider');
return ctx;
}

View File

@ -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; }

View File

@ -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;
}