Compare commits

...

12 Commits

Author SHA1 Message Date
a73bd17f47 Merge fix/lock-bypass-and-ui-polish into main
Server-persisted lock state, accent color persistence, data prefetching.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 23:14:25 +08:00
aa47ba4136 Address QA findings: prefetch reset on re-lock, settings gate, HSL validation
W-02: Reset hasPrefetched ref when app re-locks so subsequent unlocks
refresh stale cache data.
S-01: Validate localStorage HSL values with regex to prevent CSS injection.
S-05: Defer prefetch until settings are loaded for accurate upcoming_days.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:40:46 +08:00
379cc74387 Add data prefetching to eliminate skeleton flash on tab switch
Prefetches all main page queries (dashboard, upcoming, todos, reminders,
projects, people, locations) in parallel when the app unlocks, so the
TanStack Query cache is warm before the user navigates to each tab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 22:19:08 +08:00
18a2c1314a Remove @layer base cyan defaults to eliminate refresh flash
Browser paints cached Vite CSS (@layer base cyan defaults) before the
inline script populates the static style tag. Remove the competing
cyan defaults — accent vars now come exclusively from the static
<style id="umbra-accent"> tag in index.html, which the inline script
always populates with the correct color before first paint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 21:15:47 +08:00
4e1b59c0f9 Use static style tag in HTML source for accent color persistence
Vite's initialization strips dynamically created elements from <head>.
Place <style id="umbra-accent"> directly in the HTML source instead of
creating it with createElement. Source-authored elements survive Vite's
head cleanup. The inline script populates it via textContent (XSS-safe).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:47:02 +08:00
f9359bd78a Recreate accent style tag if removed during page init
The <style id="umbra-accent"> tag injected by index.html gets removed
during page initialization. useTheme now defensively recreates the tag
if it's missing, ensuring color changes from the settings page work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:39:37 +08:00
b202ee1a84 Use style tag with !important for accent color persistence
Inline style attribute on <html> gets stripped during page load.
Switch to injecting a <style id="umbra-accent"> tag with !important
CSS custom properties which persists in the DOM and beats @layer base
defaults. useTheme updates the same style tag when settings load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:29:53 +08:00
fce7405b14 Use !important inline styles for accent color to beat all CSS cascade
Both the index.html inline script and useTheme now use setProperty
with 'important' priority flag. This is the highest CSS cascade
priority and cannot be overridden by Vite's stylesheet injection,
@layer rules, or source order. Removes the <style> tag injection
approach which was being overridden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:17:04 +08:00
e7be762198 Fix accent color loss on refresh by using injected style tag
The inline script's style.setProperty values on <html> were being
stripped during Vite's CSS injection. Switch to injecting a <style>
tag with :root vars which persists in the DOM. Restore CSS defaults
as safety fallback. Update useTheme to sync both the style tag and
inline styles when settings load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:10:51 +08:00
988dc37b64 Fix accent color flash on refresh by eliminating CSS/JS race
Guard useTheme effect to skip when settings are undefined, preventing
it from overwriting the inline script's cached color with cyan defaults.
Move CSS accent var defaults from index.css :root into the index.html
inline script so they are always set synchronously before paint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 20:02:13 +08:00
3d7166740e Fix lock screen flash, theme flicker, and lock state gating
Gate dashboard rendering on isLockResolved to prevent content flash
before lock state is known. Remove animate-fade-in from LockOverlay
so it renders instantly. Always write accent color to localStorage
(even default cyan) to prevent theme flash on reload. Resolve lock
state on auth query error to avoid permanent blank screen. Lift
mobileOpen state above lock gate to survive lock/unlock cycles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:56:05 +08:00
89519a6dd3 Fix lock screen bypass, theme flicker, skeleton flash, and sidebar click target
Critical: Lock state was purely React useState — refreshing the page reset it.
Now persisted server-side via is_locked/locked_at columns on user_sessions.
POST /auth/lock sets the flag, /auth/verify-password clears it, and
GET /auth/status returns is_locked so the frontend initializes correctly.

UI: Cache accent color in localStorage and apply via inline script in
index.html before React hydrates to eliminate the cyan flash on load.

UI: Increase TanStack Query gcTime from 5min to 30min so page data
survives component unmount/remount across tab switches without skeleton.

UI: Move Projects nav onClick from the icon element to the full-width
container div so the entire row is clickable when the sidebar is collapsed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:00:55 +08:00
15 changed files with 310 additions and 75 deletions

View File

@ -0,0 +1,18 @@
"""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,6 +18,10 @@ 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,6 +132,19 @@ 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
@ -542,6 +555,8 @@ 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:
@ -556,8 +571,10 @@ async def auth_status(
UserSession.expires_at > datetime.now(), UserSession.expires_at > datetime.now(),
) )
) )
if session_result.scalar_one_or_none() is not None: db_sess = session_result.scalar_one_or_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)
) )
@ -582,12 +599,28 @@ 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),
): ):
@ -604,7 +637,12 @@ 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,6 +8,27 @@
<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,11 +20,7 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth(); const { authStatus, isLoading } = useAuth();
if (isLoading) { if (isLoading) {
return ( return <div className="h-dvh bg-background" />;
<div className="flex h-dvh items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
);
} }
if (!authStatus?.authenticated) { if (!authStatus?.authenticated) {
@ -38,11 +34,7 @@ function AdminRoute({ children }: { children: React.ReactNode }) {
const { authStatus, isLoading } = useAuth(); const { authStatus, isLoading } = useAuth();
if (isLoading) { if (isLoading) {
return ( return <div className="h-dvh bg-background" />;
<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,8 +2,9 @@ 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 } from '@/hooks/useLock'; import { LockProvider, useLock } 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';
@ -11,45 +12,68 @@ import AppAmbientBackground from './AppAmbientBackground';
import LockOverlay from './LockOverlay'; import LockOverlay from './LockOverlay';
import NotificationToaster from '@/components/notifications/NotificationToaster'; import NotificationToaster from '@/components/notifications/NotificationToaster';
export default function AppLayout() { function AppContent({ mobileOpen, setMobileOpen }: {
useTheme(); mobileOpen: boolean;
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>
<div className="flex h-dvh overflow-hidden bg-background"> <AppContent mobileOpen={mobileOpen} setMobileOpen={setMobileOpen} />
<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 animate-fade-in"> <div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-background">
<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,28 +83,20 @@ 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', 'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-all duration-200 border-l-2 cursor-pointer',
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 <FolderKanban className="h-5 w-5 shrink-0" />
className="h-5 w-5 shrink-0 cursor-pointer"
onClick={() => {
navigate('/projects');
if (mobileOpen) onMobileClose();
}}
/>
{showExpanded && ( {showExpanded && (
<> <>
<span <span className="flex-1">
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,13 +7,15 @@ import {
useRef, useRef,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { useIsMutating } from '@tanstack/react-query'; import { useIsMutating, useQueryClient } 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;
lock: () => void; isLockResolved: boolean;
lock: () => Promise<void>;
unlock: (password: string) => Promise<void>; unlock: (password: string) => Promise<void>;
} }
@ -23,7 +25,19 @@ 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 [isLocked, setIsLocked] = useState(false); const queryClient = useQueryClient();
// 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();
@ -32,8 +46,40 @@ export function LockProvider({ children }: { children: ReactNode }) {
const activeMutationsRef = useRef(activeMutations); const activeMutationsRef = useRef(activeMutations);
activeMutationsRef.current = activeMutations; activeMutationsRef.current = activeMutations;
const lock = useCallback(() => { // Subscribe to auth query updates to catch server lock state on refresh
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) => {
@ -41,8 +87,12 @@ 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(() => {
@ -97,7 +147,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, lock, unlock }}> <LockContext.Provider value={{ isLocked, isLockResolved, lock, unlock }}>
{children} {children}
</LockContext.Provider> </LockContext.Provider>
); );

View File

@ -0,0 +1,72 @@
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,16 +15,33 @@ 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?.accent_color) return; if (!settings) return;
const preset = ACCENT_PRESETS[settings.accent_color]; const colorName = settings.accent_color || 'cyan';
if (preset) { const preset = ACCENT_PRESETS[colorName];
document.documentElement.style.setProperty('--accent-h', preset.h.toString()); if (!preset) return;
document.documentElement.style.setProperty('--accent-s', `${preset.s}%`);
document.documentElement.style.setProperty('--accent-l', `${preset.l}%`); const h = preset.h.toString();
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);
} }
}, [settings?.accent_color]); el.textContent = css;
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,10 +26,11 @@
--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;
/* Default accent: cyan */ /* Accent vars (--accent-h/s/l) are NOT defined here.
--accent-h: 187; They come exclusively from <style id="umbra-accent"> in index.html
--accent-s: 85.7%; (populated by inline script from localStorage, fallback cyan).
--accent-l: 53.3%; Defining them here causes a cyan flash on refresh because the
browser paints cached CSS before the inline script executes. */
/* Transitions */ /* Transitions */
--transition-fast: 150ms; --transition-fast: 150ms;

View File

@ -20,6 +20,10 @@ 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,6 +20,7 @@ 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,6 +221,7 @@ 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