Compare commits

..

No commits in common. "a73bd17f47d799127f14a3fd8e27d31e8495bdd4" and "3dee52b6ad627fb5ffd81fcaa084a926fc40fc0b" have entirely different histories.

15 changed files with 75 additions and 310 deletions

View File

@ -1,18 +0,0 @@
"""add is_locked and locked_at to user_sessions
Revision ID: 052
Revises: 051
"""
from alembic import op
import sqlalchemy as sa
revision = "052"
down_revision = "051"
def upgrade():
op.add_column("user_sessions", sa.Column("is_locked", sa.Boolean(), server_default="false", nullable=False))
op.add_column("user_sessions", sa.Column("locked_at", sa.DateTime(), nullable=True))
def downgrade():
op.drop_column("user_sessions", "locked_at")
op.drop_column("user_sessions", "is_locked")

View File

@ -18,10 +18,6 @@ class UserSession(Base):
expires_at: Mapped[datetime] = mapped_column(nullable=False) expires_at: Mapped[datetime] = mapped_column(nullable=False)
revoked: Mapped[bool] = mapped_column(Boolean, default=False) revoked: Mapped[bool] = mapped_column(Boolean, default=False)
# Session lock — persists across page refresh
is_locked: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
locked_at: Mapped[datetime | None] = mapped_column(nullable=True)
# Audit fields for security logging # Audit fields for security logging
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True) ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True) user_agent: Mapped[str | None] = mapped_column(String(255), nullable=True)

View File

@ -132,19 +132,6 @@ async def get_current_user(
fresh_token = create_session_token(user_id, session_id) fresh_token = create_session_token(user_id, session_id)
_set_session_cookie(response, fresh_token) _set_session_cookie(response, fresh_token)
# Stash session on request so lock/unlock endpoints can access it
request.state.db_session = db_session
# Defense-in-depth: block API access while session is locked.
# Exempt endpoints needed for unlocking, locking, checking status, and logout.
if db_session.is_locked:
lock_exempt = {
"/api/auth/lock", "/api/auth/verify-password",
"/api/auth/status", "/api/auth/logout",
}
if request.url.path not in lock_exempt:
raise HTTPException(status_code=423, detail="Session is locked")
return user return user
@ -555,8 +542,6 @@ async def auth_status(
authenticated = False authenticated = False
role = None role = None
is_locked = False
if not setup_required and session_cookie: if not setup_required and session_cookie:
payload = verify_session_token(session_cookie) payload = verify_session_token(session_cookie)
if payload: if payload:
@ -571,10 +556,8 @@ async def auth_status(
UserSession.expires_at > datetime.now(), UserSession.expires_at > datetime.now(),
) )
) )
db_sess = session_result.scalar_one_or_none() if session_result.scalar_one_or_none() is not None:
if db_sess is not None:
authenticated = True authenticated = True
is_locked = db_sess.is_locked
user_obj_result = await db.execute( user_obj_result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True) select(User).where(User.id == user_id, User.is_active == True)
) )
@ -599,28 +582,12 @@ async def auth_status(
"role": role, "role": role,
"username": u.username if authenticated and u else None, "username": u.username if authenticated and u else None,
"registration_open": registration_open, "registration_open": registration_open,
"is_locked": is_locked,
} }
@router.post("/lock")
async def lock_session(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Mark the current session as locked. Frontend must verify password to unlock."""
db_session: UserSession = request.state.db_session
db_session.is_locked = True
db_session.locked_at = datetime.now()
await db.commit()
return {"locked": True}
@router.post("/verify-password") @router.post("/verify-password")
async def verify_password( async def verify_password(
data: VerifyPasswordRequest, data: VerifyPasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
@ -637,12 +604,7 @@ async def verify_password(
if new_hash: if new_hash:
current_user.password_hash = new_hash current_user.password_hash = new_hash
await db.commit()
# Clear session lock on successful password verification
db_session: UserSession = request.state.db_session
db_session.is_locked = False
db_session.locked_at = None
await db.commit()
return {"verified": True} return {"verified": True}

View File

@ -8,27 +8,6 @@
<meta name="theme-color" content="#09090b" /> <meta name="theme-color" content="#09090b" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<title>UMBRA</title> <title>UMBRA</title>
<!-- Static style tag — survives Vite's head cleanup (unlike dynamically created elements).
The inline script below populates it with accent color from localStorage cache. -->
<style id="umbra-accent"></style>
<script>
// Populate the static style tag with cached accent color before first paint.
// Uses textContent (safe from XSS) and !important (beats @layer base defaults).
(function() {
var h = '187', s = '85.7%', l = '53.3%';
try {
var c = localStorage.getItem('umbra-accent-color');
if (c) {
var p = JSON.parse(c);
if (p.h && /^\d+$/.test(p.h)) h = p.h;
if (p.s && /^\d+\.?\d*%$/.test(p.s)) s = p.s;
if (p.l && /^\d+\.?\d*%$/.test(p.l)) l = p.l;
}
} catch(e) {}
document.getElementById('umbra-accent').textContent =
':root{--accent-h:' + h + ' !important;--accent-s:' + s + ' !important;--accent-l:' + l + ' !important}';
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet" />

View File

@ -20,7 +20,11 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth(); const { authStatus, isLoading } = useAuth();
if (isLoading) { if (isLoading) {
return <div className="h-dvh bg-background" />; return (
<div className="flex h-dvh items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
} }
if (!authStatus?.authenticated) { if (!authStatus?.authenticated) {
@ -34,7 +38,11 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth(); const { authStatus, isLoading } = useAuth();
if (isLoading) { if (isLoading) {
return <div className="h-dvh bg-background" />; return (
<div className="flex h-dvh items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
} }
if (!authStatus?.authenticated || authStatus?.role !== 'admin') { if (!authStatus?.authenticated || authStatus?.role !== 'admin') {

View File

@ -2,9 +2,8 @@ import { useState } from 'react';
import { Outlet } from 'react-router-dom'; 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 { usePrefetch } from '@/hooks/usePrefetch';
import { AlertsProvider } from '@/hooks/useAlerts'; import { AlertsProvider } from '@/hooks/useAlerts';
import { LockProvider, useLock } from '@/hooks/useLock'; import { LockProvider } from '@/hooks/useLock';
import { NotificationProvider } from '@/hooks/useNotifications'; import { NotificationProvider } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
@ -12,68 +11,45 @@ import AppAmbientBackground from './AppAmbientBackground';
import LockOverlay from './LockOverlay'; import LockOverlay from './LockOverlay';
import NotificationToaster from '@/components/notifications/NotificationToaster'; import NotificationToaster from '@/components/notifications/NotificationToaster';
function AppContent({ mobileOpen, setMobileOpen }: { export default function AppLayout() {
mobileOpen: boolean; useTheme();
setMobileOpen: (v: boolean) => void;
}) {
const { isLocked, isLockResolved } = useLock();
usePrefetch(isLockResolved && !isLocked);
const [collapsed, setCollapsed] = useState(() => { const [collapsed, setCollapsed] = useState(() => {
try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); } try { return JSON.parse(localStorage.getItem('umbra-sidebar-collapsed') || 'false'); }
catch { return 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); const [mobileOpen, setMobileOpen] = useState(false);
return ( return (
<LockProvider> <LockProvider>
<AlertsProvider> <AlertsProvider>
<NotificationProvider> <NotificationProvider>
<AppContent mobileOpen={mobileOpen} setMobileOpen={setMobileOpen} /> <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>
<LockOverlay />
<NotificationToaster />
</NotificationProvider> </NotificationProvider>
</AlertsProvider> </AlertsProvider>
</LockProvider> </LockProvider>

View File

@ -56,7 +56,7 @@ export default function LockOverlay() {
}; };
return ( return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background"> <div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background animate-fade-in">
<AmbientBackground /> <AmbientBackground />
<div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-sm px-4 animate-slide-up"> <div className="relative z-10 flex flex-col items-center gap-6 w-full max-w-sm px-4 animate-slide-up">

View File

@ -83,20 +83,28 @@ export default function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose
<div> <div>
<div <div
className={cn( className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200 border-l-2 cursor-pointer', 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200 border-l-2',
isProjectsActive isProjectsActive
? 'bg-accent/15 text-accent border-accent' ? 'bg-accent/15 text-accent border-accent'
: 'text-muted-foreground hover:bg-accent/10 hover:text-accent border-transparent' : 'text-muted-foreground hover:bg-accent/10 hover:text-accent border-transparent'
)} )}
onClick={() => {
navigate('/projects');
if (mobileOpen) onMobileClose();
}}
> >
<FolderKanban className="h-5 w-5 shrink-0" /> <FolderKanban
className="h-5 w-5 shrink-0 cursor-pointer"
onClick={() => {
navigate('/projects');
if (mobileOpen) onMobileClose();
}}
/>
{showExpanded && ( {showExpanded && (
<> <>
<span className="flex-1"> <span
className="flex-1 cursor-pointer"
onClick={() => {
navigate('/projects');
if (mobileOpen) onMobileClose();
}}
>
Projects Projects
</span> </span>
{trackedProjects && trackedProjects.length > 0 && ( {trackedProjects && trackedProjects.length > 0 && (

View File

@ -7,15 +7,13 @@ import {
useRef, useRef,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { useIsMutating, useQueryClient } from '@tanstack/react-query'; import { useIsMutating } from '@tanstack/react-query';
import { useSettings } from '@/hooks/useSettings'; import { useSettings } from '@/hooks/useSettings';
import api from '@/lib/api'; import api from '@/lib/api';
import type { AuthStatus } from '@/types';
interface LockContextValue { interface LockContextValue {
isLocked: boolean; isLocked: boolean;
isLockResolved: boolean; lock: () => void;
lock: () => Promise<void>;
unlock: (password: string) => Promise<void>; unlock: (password: string) => Promise<void>;
} }
@ -25,19 +23,7 @@ const ACTIVITY_EVENTS = ['mousemove', 'keydown', 'click', 'scroll', 'touchstart'
const THROTTLE_MS = 5_000; // only reset timer every 5s of activity const THROTTLE_MS = 5_000; // only reset timer every 5s of activity
export function LockProvider({ children }: { children: ReactNode }) { export function LockProvider({ children }: { children: ReactNode }) {
const queryClient = useQueryClient(); const [isLocked, setIsLocked] = useState(false);
// Initialize lock state from the auth status cache (server-persisted)
const [isLocked, setIsLocked] = useState(() => {
const cached = queryClient.getQueryData<AuthStatus>(['auth']);
return cached?.is_locked ?? false;
});
// Track whether lock state has been definitively resolved from the server
const [isLockResolved, setIsLockResolved] = useState(() => {
return queryClient.getQueryData<AuthStatus>(['auth']) !== undefined;
});
const { settings } = useSettings(); const { settings } = useSettings();
const activeMutations = useIsMutating(); const activeMutations = useIsMutating();
@ -46,40 +32,8 @@ export function LockProvider({ children }: { children: ReactNode }) {
const activeMutationsRef = useRef(activeMutations); const activeMutationsRef = useRef(activeMutations);
activeMutationsRef.current = activeMutations; activeMutationsRef.current = activeMutations;
// Subscribe to auth query updates to catch server lock state on refresh const lock = useCallback(() => {
useEffect(() => {
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (
event.type === 'updated' &&
(event.action.type === 'success' || event.action.type === 'error') &&
event.query.queryKey[0] === 'auth'
) {
setIsLockResolved(true);
if (event.action.type === 'success') {
const data = event.query.state.data as AuthStatus | undefined;
if (data?.is_locked) {
setIsLocked(true);
}
}
}
});
return unsubscribe;
}, [queryClient]);
// Listen for 423 responses from the API interceptor (server-side lock enforcement)
useEffect(() => {
const handler = () => setIsLocked(true);
window.addEventListener('umbra:session-locked', handler);
return () => window.removeEventListener('umbra:session-locked', handler);
}, []);
const lock = useCallback(async () => {
setIsLocked(true); setIsLocked(true);
try {
await api.post('/auth/lock');
} catch {
// Lock locally even if server call fails — defense in depth
}
}, []); }, []);
const unlock = useCallback(async (password: string) => { const unlock = useCallback(async (password: string) => {
@ -87,12 +41,8 @@ export function LockProvider({ children }: { children: ReactNode }) {
if (data.verified) { if (data.verified) {
setIsLocked(false); setIsLocked(false);
lastActivityRef.current = Date.now(); lastActivityRef.current = Date.now();
// Update auth cache to reflect unlocked state
queryClient.setQueryData<AuthStatus>(['auth'], (old) =>
old ? { ...old, is_locked: false } : old
);
} }
}, [queryClient]); }, []);
// Auto-lock idle timer // Auto-lock idle timer
useEffect(() => { useEffect(() => {
@ -147,7 +97,7 @@ export function LockProvider({ children }: { children: ReactNode }) {
}, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]); }, [settings?.auto_lock_enabled, settings?.auto_lock_minutes, isLocked, lock]);
return ( return (
<LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}> <LockContext.Provider value={{ isLocked, lock, unlock }}>
{children} {children}
</LockContext.Provider> </LockContext.Provider>
); );

View File

@ -1,72 +0,0 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { useSettings } from './useSettings';
/**
* Prefetches main page data in the background after the app unlocks.
* Ensures cache is warm before the user navigates to each tab,
* eliminating the skeleton flash on first visit.
*/
export function usePrefetch(enabled: boolean) {
const queryClient = useQueryClient();
const { settings } = useSettings();
const hasPrefetched = useRef(false);
const prevEnabled = useRef(false);
// Reset on re-lock so subsequent unlocks refresh stale data (W-02)
useEffect(() => {
if (prevEnabled.current && !enabled) {
hasPrefetched.current = false;
}
prevEnabled.current = enabled;
}, [enabled]);
useEffect(() => {
// Wait for settings to load so upcoming_days is accurate (S-05)
if (!enabled || hasPrefetched.current || !settings) return;
hasPrefetched.current = true;
const now = new Date();
const clientDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const days = settings.upcoming_days || 7;
// Prefetch all main page queries (no-op if already cached and fresh)
queryClient.prefetchQuery({
queryKey: ['dashboard'],
queryFn: () => api.get(`/dashboard?client_date=${clientDate}`).then(r => r.data),
staleTime: 60_000,
});
queryClient.prefetchQuery({
queryKey: ['upcoming', days],
queryFn: () => api.get(`/upcoming?days=${days}&client_date=${clientDate}`).then(r => r.data),
staleTime: 60_000,
});
queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: () => api.get('/todos').then(r => r.data),
});
queryClient.prefetchQuery({
queryKey: ['reminders'],
queryFn: () => api.get('/reminders').then(r => r.data),
});
queryClient.prefetchQuery({
queryKey: ['projects'],
queryFn: () => api.get('/projects').then(r => r.data),
});
queryClient.prefetchQuery({
queryKey: ['people'],
queryFn: () => api.get('/people').then(r => r.data),
});
queryClient.prefetchQuery({
queryKey: ['locations'],
queryFn: () => api.get('/locations').then(r => r.data),
});
}, [enabled, queryClient, settings]);
}

View File

@ -15,33 +15,16 @@ const ACCENT_PRESETS: Record<string, { h: number; s: number; l: number }> = {
export function useTheme() { export function useTheme() {
const { settings } = useSettings(); const { settings } = useSettings();
// Only apply accent color once settings have loaded from the API.
// Creates or updates a <style id="umbra-accent"> tag with !important vars.
// The tag may be removed during page init, so always recreate if missing.
useEffect(() => { useEffect(() => {
if (!settings) return; if (!settings?.accent_color) return;
const colorName = settings.accent_color || 'cyan'; const preset = ACCENT_PRESETS[settings.accent_color];
const preset = ACCENT_PRESETS[colorName]; if (preset) {
if (!preset) return; document.documentElement.style.setProperty('--accent-h', preset.h.toString());
document.documentElement.style.setProperty('--accent-s', `${preset.s}%`);
const h = preset.h.toString(); document.documentElement.style.setProperty('--accent-l', `${preset.l}%`);
const s = `${preset.s}%`;
const l = `${preset.l}%`;
const css = `:root{--accent-h:${h} !important;--accent-s:${s} !important;--accent-l:${l} !important}`;
let el = document.getElementById('umbra-accent');
if (!el) {
el = document.createElement('style');
el.id = 'umbra-accent';
document.head.appendChild(el);
} }
el.textContent = css; }, [settings?.accent_color]);
try {
localStorage.setItem('umbra-accent-color', JSON.stringify({ h, s, l }));
} catch {}
}, [settings]);
return { return {
accentColor: settings?.accent_color || 'cyan', accentColor: settings?.accent_color || 'cyan',

View File

@ -26,11 +26,10 @@
--ring: var(--accent-h) var(--accent-s) var(--accent-l); --ring: var(--accent-h) var(--accent-s) var(--accent-l);
--radius: 0.5rem; --radius: 0.5rem;
/* Accent vars (--accent-h/s/l) are NOT defined here. /* Default accent: cyan */
They come exclusively from <style id="umbra-accent"> in index.html --accent-h: 187;
(populated by inline script from localStorage, fallback cyan). --accent-s: 85.7%;
Defining them here causes a cyan flash on refresh because the --accent-l: 53.3%;
browser paints cached CSS before the inline script executes. */
/* Transitions */ /* Transitions */
--transition-fast: 150ms; --transition-fast: 150ms;

View File

@ -20,10 +20,6 @@ api.interceptors.response.use(
window.location.href = '/login'; window.location.href = '/login';
} }
} }
// 423 = session is locked server-side — trigger lock screen
if (error.response?.status === 423) {
window.dispatchEvent(new CustomEvent('umbra:session-locked'));
}
return Promise.reject(error); return Promise.reject(error);
} }
); );

View File

@ -20,7 +20,6 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 1, retry: 1,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000, // keep cache 30 min to avoid skeleton flash on tab switch
}, },
}, },
}); });

View File

@ -221,7 +221,6 @@ export interface AuthStatus {
role: UserRole | null; role: UserRole | null;
username: string | null; username: string | null;
registration_open: boolean; registration_open: boolean;
is_locked: boolean;
} }
// Login response discriminated union // Login response discriminated union