diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index e624df4..ebe16dd 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -1,6 +1,12 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { User } from '../types' +const SHARED_TIMEOUT_MS = 10 * 60 * 1000 + +function isSharedDevice() { + return document.cookie.split(';').some(c => c.trim() === 'hnf_shared_device=1') +} + interface Props { children: (user: User) => React.ReactNode } @@ -23,6 +29,7 @@ export function AuthGate({ children }: Props) { const [password, setPassword] = useState('') const [error, setError] = useState('') const [loading, setLoading] = useState(false) + const timerRef = useRef | null>(null) useEffect(() => { fetch('/api/auth/verify?app=cashup', { credentials: 'include' }) @@ -33,6 +40,30 @@ export function AuthGate({ children }: Props) { .catch(() => setState('login')) }, []) + useEffect(() => { + if (state !== 'authed' || !isSharedDevice()) return + + async function forceLogout() { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) + setUser(null) + setState('login') + } + + function reset() { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(forceLogout, SHARED_TIMEOUT_MS) + } + + const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const + events.forEach(e => window.addEventListener(e, reset, { passive: true })) + reset() + + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + events.forEach(e => window.removeEventListener(e, reset)) + } + }, [state]) + async function login(e: React.FormEvent) { e.preventDefault() setLoading(true) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 08e0537..f830a89 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -45,7 +45,7 @@ export function Layout({ user, children }: Props) {

{user.name}

-
+
{navItems.filter(item => !item.cap || can(user, item.cap)).map(({ to, label, icon: Icon }) => ( ({ display: 'flex', alignItems: 'center', gap: '0.625rem', diff --git a/frontend/src/index.css b/frontend/src/index.css index 02145b4..29f6a49 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -35,6 +35,21 @@ body { button { cursor: pointer; font-family: inherit; } input, textarea, select { font-family: inherit; } +/* Sidebar scrollbar */ +.nav-scroll::-webkit-scrollbar, +.sidebar::-webkit-scrollbar, +.sidebar-nav::-webkit-scrollbar { width: 4px; } +.nav-scroll::-webkit-scrollbar-track, +.sidebar::-webkit-scrollbar-track, +.sidebar-nav::-webkit-scrollbar-track { background: transparent; } +.nav-scroll::-webkit-scrollbar-thumb, +.sidebar::-webkit-scrollbar-thumb, +.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(201,168,76,0.35); border-radius: 2px; } +.nav-scroll::-webkit-scrollbar-thumb:hover, +.sidebar::-webkit-scrollbar-thumb:hover, +.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); } +.nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; } + @media print { nav { display: none !important; } main { overflow: visible !important; } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 61f27c1..ecd7fbf 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -5,6 +5,16 @@ import { can, fmtGBP, type User } from '../types' const DEFAULT_MACHINE_NAMES = ['Front Desk', 'Restaurant / Bar'] +function isSharedDevice() { + return document.cookie.split(';').some(c => c.trim() === 'hnf_shared_device=1') +} + +function setSharedDeviceCookie(shared: boolean) { + document.cookie = shared + ? 'hnf_shared_device=1; max-age=31536000; path=/; SameSite=Strict' + : 'hnf_shared_device=; max-age=0; path=/; SameSite=Strict' +} + interface SettingsData { default_report_days: string petty_cash_float: string @@ -42,6 +52,7 @@ export function SettingsPage({ user }: { user: User }) { const [testing, setTesting] = useState(false) const [refreshing, setRefreshing] = useState(false) const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null) + const [isShared, setIsShared] = useState(isSharedDevice) function flash(text: string, ok = true) { setMsg({ text, ok }) @@ -332,6 +343,31 @@ export function SettingsPage({ user }: { user: User }) { )} + +
+
+

This Device

+

+ {isShared + ? 'Marked as a shared computer — sessions time out after 10 minutes of inactivity.' + : 'Mark this as a shared computer to automatically log out after 10 minutes of inactivity.'} +

+
+ { + const next = !isShared + setSharedDeviceCookie(next) + setIsShared(next) + flash(next ? 'This device is now marked as shared.' : 'Shared device mode removed.') + }} + > + {isShared ? 'Remove Shared Status' : 'Mark as Shared'} + +
+
+ {saving ? 'Saving…' : 'Save Settings'}