Add shared device session timeout, sidebar scrollbar styling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
37453ddfda
commit
dec7ac2f3e
4 changed files with 84 additions and 2 deletions
|
|
@ -1,6 +1,12 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { User } from '../types'
|
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 {
|
interface Props {
|
||||||
children: (user: User) => React.ReactNode
|
children: (user: User) => React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
@ -23,6 +29,7 @@ export function AuthGate({ children }: Props) {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/auth/verify?app=cashup', { credentials: 'include' })
|
fetch('/api/auth/verify?app=cashup', { credentials: 'include' })
|
||||||
|
|
@ -33,6 +40,30 @@ export function AuthGate({ children }: Props) {
|
||||||
.catch(() => setState('login'))
|
.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) {
|
async function login(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export function Layout({ user, children }: Props) {
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginTop: '0.25rem' }}>{user.name}</p>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.75rem', marginTop: '0.25rem' }}>{user.name}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
|
<div className="nav-scroll" style={{ flex: 1, padding: '0.5rem 0', overflowY: 'auto' }}>
|
||||||
{navItems.filter(item => !item.cap || can(user, item.cap)).map(({ to, label, icon: Icon }) => (
|
{navItems.filter(item => !item.cap || can(user, item.cap)).map(({ to, label, icon: Icon }) => (
|
||||||
<NavLink key={to} to={to} style={({ isActive }) => ({
|
<NavLink key={to} to={to} style={({ isActive }) => ({
|
||||||
display: 'flex', alignItems: 'center', gap: '0.625rem',
|
display: 'flex', alignItems: 'center', gap: '0.625rem',
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,21 @@ body {
|
||||||
button { cursor: pointer; font-family: inherit; }
|
button { cursor: pointer; font-family: inherit; }
|
||||||
input, textarea, select { 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 {
|
@media print {
|
||||||
nav { display: none !important; }
|
nav { display: none !important; }
|
||||||
main { overflow: visible !important; }
|
main { overflow: visible !important; }
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,16 @@ import { can, fmtGBP, type User } from '../types'
|
||||||
|
|
||||||
const DEFAULT_MACHINE_NAMES = ['Front Desk', 'Restaurant / Bar']
|
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 {
|
interface SettingsData {
|
||||||
default_report_days: string
|
default_report_days: string
|
||||||
petty_cash_float: string
|
petty_cash_float: string
|
||||||
|
|
@ -42,6 +52,7 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
const [testing, setTesting] = useState(false)
|
const [testing, setTesting] = useState(false)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
||||||
|
const [isShared, setIsShared] = useState(isSharedDevice)
|
||||||
|
|
||||||
function flash(text: string, ok = true) {
|
function flash(text: string, ok = true) {
|
||||||
setMsg({ text, ok })
|
setMsg({ text, ok })
|
||||||
|
|
@ -332,6 +343,31 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>This Device</h2>
|
||||||
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginTop: '0.25rem' }}>
|
||||||
|
{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.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Btn
|
||||||
|
variant={isShared ? 'danger' : 'secondary'}
|
||||||
|
small
|
||||||
|
onClick={() => {
|
||||||
|
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'}
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Btn onClick={save} disabled={saving}>
|
<Btn onClick={save} disabled={saving}>
|
||||||
{saving ? 'Saving…' : 'Save Settings'}
|
{saving ? 'Saving…' : 'Save Settings'}
|
||||||
</Btn>
|
</Btn>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue