Compare commits
No commits in common. "a73bd17f47d799127f14a3fd8e27d31e8495bdd4" and "3dee52b6ad627fb5ffd81fcaa084a926fc40fc0b" have entirely different histories.
a73bd17f47
...
3dee52b6ad
@ -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")
|
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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}
|
||||||
|
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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]);
|
|
||||||
}
|
|
||||||
@ -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',
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user