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:
parent
3dee52b6ad
commit
89519a6dd3
18
backend/alembic/versions/052_add_session_lock_columns.py
Normal file
18
backend/alembic/versions/052_add_session_lock_columns.py
Normal 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")
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user