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:
parent
e5b6725081
commit
b0af07c270
30
backend/alembic/versions/025_add_auto_lock_settings.py
Normal file
30
backend/alembic/versions/025_add_auto_lock_settings.py
Normal 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")
|
||||
@ -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."""
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
45
frontend/src/components/auth/AmbientBackground.tsx
Normal file
45
frontend/src/components/auth/AmbientBackground.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
112
frontend/src/components/layout/LockOverlay.tsx
Normal file
112
frontend/src/components/layout/LockOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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 />
|
||||
|
||||
|
||||
108
frontend/src/hooks/useLock.tsx
Normal file
108
frontend/src/hooks/useLock.tsx
Normal 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;
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user