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

View File

@ -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 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") @router.post("/change-password")
async def change_password( async def change_password(
data: ChangePasswordRequest, 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_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,
) )

View File

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

View File

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

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

View File

@ -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,6 +15,7 @@ export default function AppLayout() {
return ( return (
<AlertsProvider> <AlertsProvider>
<LockProvider>
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-screen overflow-hidden bg-background">
<Sidebar <Sidebar
collapsed={collapsed} collapsed={collapsed}
@ -33,6 +36,8 @@ export default function AppLayout() {
</main> </main>
</div> </div>
</div> </div>
<LockOverlay />
</LockProvider>
</AlertsProvider> </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, 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}

View File

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

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

View File

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