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>
104 lines
3.4 KiB
TypeScript
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>
|
|
);
|
|
}
|