Kyle Pope 53101d1401 Action deferred review items: TOTP lockout consolidation + toast nav
W-04: Replace inline lockout logic in totp.py (3 occurrences of
manual failed_login_count/locked_until manipulation) with shared
session service calls: check_account_lockout, record_failed_login,
record_successful_login. Also fix TOTP replay prevention to use
flush() not commit() for atomicity with session creation.

S-1: Add "Set up" action button to the post-login passkey prompt
toast, navigating to /settings?tab=security (already supported by
SettingsPage search params).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:02:59 +08:00

104 lines
3.4 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Outlet, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Menu } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
import { usePrefetch } from '@/hooks/usePrefetch';
import { AlertsProvider } from '@/hooks/useAlerts';
import { LockProvider, useLock } from '@/hooks/useLock';
import { NotificationProvider } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button';
import Sidebar from './Sidebar';
import AppAmbientBackground from './AppAmbientBackground';
import LockOverlay from './LockOverlay';
import NotificationToaster from '@/components/notifications/NotificationToaster';
function AppContent({ mobileOpen, setMobileOpen }: {
mobileOpen: boolean;
setMobileOpen: (v: boolean) => void;
}) {
const { isLocked, isLockResolved } = useLock();
const { hasPasskeys } = useAuth();
const navigate = useNavigate();
usePrefetch(isLockResolved && !isLocked);
// Post-login passkey prompt — show once per session if user has no passkeys
useEffect(() => {
if (
isLockResolved && !isLocked && !hasPasskeys &&
window.PublicKeyCredential &&
!sessionStorage.getItem('passkey-prompt-shown')
) {
sessionStorage.setItem('passkey-prompt-shown', '1');
toast.info('Simplify your login \u2014 set up a passkey in Settings', {
duration: 8000,
action: {
label: 'Set up',
onClick: () => navigate('/settings?tab=security'),
},
});
}
}, [isLockResolved, isLocked, hasPasskeys, navigate]);
const [collapsed, setCollapsed] = useState(() => {
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
catch { return false; }
});
// Don't render any content until we know the lock state
if (!isLockResolved || isLocked) {
return (
<>
<div className="h-dvh bg-background" />
{isLockResolved && <LockOverlay />}
</>
);
}
return (
<>
<div className="flex h-dvh overflow-hidden bg-background">
<Sidebar
collapsed={collapsed}
onToggle={() => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem('umbra-sidebar-collapsed', JSON.stringify(next));
}}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
<div className="flex-1 flex flex-col overflow-hidden relative ambient-glass">
<AppAmbientBackground />
{/* Mobile header */}
<div className="relative z-10 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="relative z-10 flex-1 overflow-y-auto mobile-scale">
<Outlet />
</main>
</div>
</div>
<NotificationToaster />
</>
);
}
export default function AppLayout() {
useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<LockProvider>
<AlertsProvider>
<NotificationProvider>
<AppContent mobileOpen={mobileOpen} setMobileOpen={setMobileOpen} />
</NotificationProvider>
</AlertsProvider>
</LockProvider>
);
}