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_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")
|
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
|
@property
|
||||||
def ntfy_has_token(self) -> bool:
|
def ntfy_has_token(self) -> bool:
|
||||||
"""Derived field for SettingsResponse — True when an auth token is stored."""
|
"""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.user import User
|
||||||
from app.models.session import UserSession
|
from app.models.session import UserSession
|
||||||
from app.models.settings import Settings
|
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 (
|
from app.services.auth import (
|
||||||
hash_password,
|
hash_password,
|
||||||
verify_password_with_upgrade,
|
verify_password_with_upgrade,
|
||||||
@ -373,6 +373,29 @@ async def auth_status(
|
|||||||
return {"authenticated": authenticated, "setup_required": setup_required}
|
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")
|
@router.post("/change-password")
|
||||||
async def change_password(
|
async def change_password(
|
||||||
data: ChangePasswordRequest,
|
data: ChangePasswordRequest,
|
||||||
|
|||||||
@ -36,6 +36,8 @@ def _to_settings_response(s: Settings) -> SettingsResponse:
|
|||||||
ntfy_todo_lead_days=s.ntfy_todo_lead_days,
|
ntfy_todo_lead_days=s.ntfy_todo_lead_days,
|
||||||
ntfy_project_lead_days=s.ntfy_project_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
|
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,
|
created_at=s.created_at,
|
||||||
updated_at=s.updated_at,
|
updated_at=s.updated_at,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -60,3 +60,7 @@ class ChangePasswordRequest(BaseModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def validate_new_password(cls, v: str) -> str:
|
def validate_new_password(cls, v: str) -> str:
|
||||||
return _validate_password_strength(v)
|
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_todo_lead_days: Optional[int] = None
|
||||||
ntfy_project_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')
|
@field_validator('first_day_of_week')
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_first_day(cls, v: int | None) -> int | None:
|
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)
|
# Derived field: computed via Settings.ntfy_has_token property (from_attributes reads it)
|
||||||
ntfy_has_token: bool = False
|
ntfy_has_token: bool = False
|
||||||
|
|
||||||
|
# Auto-lock settings
|
||||||
|
auto_lock_enabled: bool = False
|
||||||
|
auto_lock_minutes: int = 5
|
||||||
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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 { Button } from '@/components/ui/button';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import AmbientBackground from './AmbientBackground';
|
||||||
|
|
||||||
/** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */
|
/** Validates password against backend rules: 12-128 chars, at least one letter + one non-letter. */
|
||||||
function validatePassword(password: string): string | null {
|
function validatePassword(password: string): string | null {
|
||||||
@ -92,20 +93,10 @@ export default function LockScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background p-4 overflow-hidden">
|
<div className="relative flex min-h-screen flex-col items-center justify-center bg-background p-4 overflow-hidden">
|
||||||
{/* Ambient glow blobs */}
|
<AmbientBackground />
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Wordmark — in flex flow above card */}
|
{/* 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
|
UMBRA
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,10 @@ import { Outlet } from 'react-router-dom';
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { AlertsProvider } from '@/hooks/useAlerts';
|
import { AlertsProvider } from '@/hooks/useAlerts';
|
||||||
|
import { LockProvider } from '@/hooks/useLock';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Sidebar from './Sidebar';
|
import Sidebar from './Sidebar';
|
||||||
|
import LockOverlay from './LockOverlay';
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
useTheme();
|
useTheme();
|
||||||
@ -13,26 +15,29 @@ export default function AppLayout() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertsProvider>
|
<AlertsProvider>
|
||||||
<div className="flex h-screen overflow-hidden bg-background">
|
<LockProvider>
|
||||||
<Sidebar
|
<div className="flex h-screen overflow-hidden bg-background">
|
||||||
collapsed={collapsed}
|
<Sidebar
|
||||||
onToggle={() => setCollapsed(!collapsed)}
|
collapsed={collapsed}
|
||||||
mobileOpen={mobileOpen}
|
onToggle={() => setCollapsed(!collapsed)}
|
||||||
onMobileClose={() => setMobileOpen(false)}
|
mobileOpen={mobileOpen}
|
||||||
/>
|
onMobileClose={() => setMobileOpen(false)}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
/>
|
||||||
{/* Mobile header */}
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
|
{/* Mobile header */}
|
||||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
|
<div className="flex md:hidden items-center h-14 border-b bg-card px-4">
|
||||||
<Menu className="h-5 w-5" />
|
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
|
||||||
</Button>
|
<Menu className="h-5 w-5" />
|
||||||
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
|
</Button>
|
||||||
|
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
|
||||||
|
</div>
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<main className="flex-1 overflow-y-auto">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<LockOverlay />
|
||||||
|
</LockProvider>
|
||||||
</AlertsProvider>
|
</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,
|
ChevronDown,
|
||||||
X,
|
X,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useLock } from '@/hooks/useLock';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { Project } from '@/types';
|
import type { Project } from '@/types';
|
||||||
@ -42,6 +44,7 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
const { lock } = useLock();
|
||||||
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
const [projectsExpanded, setProjectsExpanded] = useState(false);
|
||||||
|
|
||||||
const { data: trackedProjects } = useQuery({
|
const { data: trackedProjects } = useQuery({
|
||||||
@ -180,6 +183,13 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t p-2 space-y-1">
|
<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
|
<NavLink
|
||||||
to="/settings"
|
to="/settings"
|
||||||
onClick={mobileOpen ? onMobileClose : undefined}
|
onClick={mobileOpen ? onMobileClose : undefined}
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Search,
|
Search,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Shield,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useSettings } from '@/hooks/useSettings';
|
import { useSettings } from '@/hooks/useSettings';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { cn } from '@/lib/utils';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import type { GeoLocation } from '@/types';
|
import type { GeoLocation } from '@/types';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import TotpSetupSection from './TotpSetupSection';
|
import TotpSetupSection from './TotpSetupSection';
|
||||||
import NtfySettingsSection from './NtfySettingsSection';
|
import NtfySettingsSection from './NtfySettingsSection';
|
||||||
|
|
||||||
@ -48,6 +50,8 @@ export default function SettingsPage() {
|
|||||||
const searchRef = useRef<HTMLDivElement>(null);
|
const searchRef = useRef<HTMLDivElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
const [firstDayOfWeek, setFirstDayOfWeek] = useState(settings?.first_day_of_week ?? 0);
|
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
|
// Sync state when settings load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -56,6 +60,8 @@ export default function SettingsPage() {
|
|||||||
setUpcomingDays(settings.upcoming_days);
|
setUpcomingDays(settings.upcoming_days);
|
||||||
setPreferredName(settings.preferred_name ?? '');
|
setPreferredName(settings.preferred_name ?? '');
|
||||||
setFirstDayOfWeek(settings.first_day_of_week);
|
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)
|
}, [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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Page header — matches Stage 4-5 pages */}
|
{/* Page header — matches Stage 4-5 pages */}
|
||||||
@ -349,9 +380,56 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Right column: Authentication, Calendar, Dashboard, Integrations ── */}
|
{/* ── Right column: Security, Authentication, Calendar, Dashboard, Integrations ── */}
|
||||||
<div className="space-y-6">
|
<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) */}
|
{/* Authentication (TOTP + password change) */}
|
||||||
<TotpSetupSection />
|
<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));
|
color: hsl(var(--accent-color));
|
||||||
font-weight: 600;
|
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_todo_lead_days: number;
|
||||||
ntfy_project_lead_days: number;
|
ntfy_project_lead_days: number;
|
||||||
ntfy_has_token: boolean;
|
ntfy_has_token: boolean;
|
||||||
|
// Auto-lock settings
|
||||||
|
auto_lock_enabled: boolean;
|
||||||
|
auto_lock_minutes: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user