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>
This commit is contained in:
Kyle 2026-03-12 19:00:55 +08:00
parent 3dee52b6ad
commit 89519a6dd3
9 changed files with 132 additions and 24 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,9 @@ 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
return user return user
@ -542,6 +545,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 +561,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 +589,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,6 +627,11 @@ async def verify_password(
if new_hash: if new_hash:
current_user.password_hash = new_hash current_user.password_hash = new_hash
# 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() await db.commit()
return {"verified": True} return {"verified": True}

View File

@ -8,6 +8,21 @@
<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>
<script>
// Apply cached accent color before React hydrates to prevent cyan flash
(function() {
try {
var c = localStorage.getItem('umbra-accent-color');
if (c) {
var p = JSON.parse(c);
var s = document.documentElement.style;
s.setProperty('--accent-h', p.h);
s.setProperty('--accent-s', p.s);
s.setProperty('--accent-l', p.l);
}
} catch(e) {}
})();
</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

@ -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'
)} )}
>
<FolderKanban
className="h-5 w-5 shrink-0 cursor-pointer"
onClick={() => { onClick={() => {
navigate('/projects'); navigate('/projects');
if (mobileOpen) onMobileClose(); if (mobileOpen) onMobileClose();
}} }}
/> >
<FolderKanban className="h-5 w-5 shrink-0" />
{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,9 +7,10 @@ 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;
@ -23,7 +24,14 @@ 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;
});
const { settings } = useSettings(); const { settings } = useSettings();
const activeMutations = useIsMutating(); const activeMutations = useIsMutating();
@ -32,8 +40,38 @@ export function LockProvider({ children }: { children: ReactNode }) {
const activeMutationsRef = useRef(activeMutations); const activeMutationsRef = useRef(activeMutations);
activeMutationsRef.current = activeMutations; activeMutationsRef.current = activeMutations;
const lock = useCallback(() => { // Sync lock state when auth status is fetched/refetched (e.g. on page refresh)
useEffect(() => {
const cached = queryClient.getQueryData<AuthStatus>(['auth']);
if (cached?.is_locked && !isLocked) {
setIsLocked(true); setIsLocked(true);
}
}, [queryClient, isLocked]);
// Subscribe to auth query updates to catch server lock state changes
useEffect(() => {
const unsubscribe = queryClient.getQueryCache().subscribe((event) => {
if (
event.type === 'updated' &&
event.action.type === 'success' &&
event.query.queryKey[0] === 'auth'
) {
const data = event.query.state.data as AuthStatus | undefined;
if (data?.is_locked) {
setIsLocked(true);
}
}
});
return unsubscribe;
}, [queryClient]);
const lock = useCallback(async () => {
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 +79,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(() => {

View File

@ -20,9 +20,16 @@ export function useTheme() {
const preset = ACCENT_PRESETS[settings.accent_color]; const preset = ACCENT_PRESETS[settings.accent_color];
if (preset) { if (preset) {
document.documentElement.style.setProperty('--accent-h', preset.h.toString()); const h = preset.h.toString();
document.documentElement.style.setProperty('--accent-s', `${preset.s}%`); const s = `${preset.s}%`;
document.documentElement.style.setProperty('--accent-l', `${preset.l}%`); const l = `${preset.l}%`;
document.documentElement.style.setProperty('--accent-h', h);
document.documentElement.style.setProperty('--accent-s', s);
document.documentElement.style.setProperty('--accent-l', l);
// Cache for next page load to prevent cyan flash
try {
localStorage.setItem('umbra-accent-color', JSON.stringify({ h, s, l }));
} catch {}
} }
}, [settings?.accent_color]); }, [settings?.accent_color]);

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