diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx index 7e6d832..72e297a 100644 --- a/frontend/src/components/AuthGate.tsx +++ b/frontend/src/components/AuthGate.tsx @@ -1,6 +1,21 @@ -import { useEffect, useState, createContext, useContext } from 'react' +import { useEffect, useRef, useState, createContext, useContext } from 'react' import type { User } from '../types' +function getInactivityMs(): number | null { + if (window.matchMedia('(display-mode: standalone)').matches) return null + const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins=')) + if (!c) return null + const mins = parseInt(c.split('=')[1]) + return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000 +} + +// Only bounce to the central login when actually embedded in the portal shell. +// A standalone PWA or a directly-opened browser tab must never navigate away +// from its own start_url/scope — otherwise it loses its installed-app context. +function isEmbedded() { + return window.top !== window +} + interface AuthCtx { user: User } const Ctx = createContext(null) @@ -11,22 +26,83 @@ export function useAuth() { } export default function AuthGate({ children }: { children: React.ReactNode }) { + const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking') const [user, setUser] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const timerRef = useRef | null>(null) useEffect(() => { fetch('/wages/api/auth/verify?app=wages', { credentials: 'include' }) - .then(r => { - if (!r.ok) { - ;(window.top ?? window).location.href = '/login' - return null + .then(async r => { + if (r.ok) { + setUser(await r.json()) + setState('authed') + } else if (isEmbedded()) { + window.top!.location.href = `/login?from=${encodeURIComponent('/app/wages')}` + } else { + setState('login') } - return r.json() }) - .then(data => { if (data) setUser(data) }) - .catch(() => { ;(window.top ?? window).location.href = '/login' }) + .catch(() => { if (!isEmbedded()) setState('login') }) }, []) - if (!user) { + // Inactivity auto-logout — disabled for installed PWAs; configurable per + // device (Admin Settings → Device) for shared/front-desk browser sessions. + useEffect(() => { + const ms = getInactivityMs() + if (state !== 'authed' || !ms) return + const timeoutMs: number = ms + + async function forceLogout() { + await fetch('/wages/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}) + setUser(null) + setState('login') + } + + function reset() { + if (timerRef.current) clearTimeout(timerRef.current) + timerRef.current = setTimeout(forceLogout, timeoutMs) + } + + 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) + setError('') + try { + const res = await fetch('/wages/api/auth/login', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) { setError('Invalid email or password'); return } + const verify = await fetch('/wages/api/auth/verify?app=wages', { credentials: 'include' }) + if (verify.ok) { + setUser(await verify.json()) + setState('authed') + } else { + setError("You don't have access to this app.") + } + } catch { + setError('Connection error — please try again') + } finally { + setLoading(false) + } + } + + if (state === 'checking') { return (
{children} + if (state === 'login') { + return ( +
+
+

+ Wage Costs +

+
+ setEmail(e.target.value)} + placeholder="Email" required autoComplete="email" style={inputStyle} /> + setPassword(e.target.value)} + placeholder="Password" required autoComplete="current-password" style={inputStyle} /> + {error &&

{error}

} + +
+
+
+ ) + } + + return {children} +} + +const inputStyle: React.CSSProperties = { + background: '#0f0f20', border: '1px solid rgba(255,255,255,0.08)', + borderRadius: '6px', color: 'rgba(255,255,255,0.88)', padding: '0.625rem 0.75rem', + fontSize: '1rem', width: '100%', outline: 'none', } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ac822ba..550b9d4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ name: 'Wage Costs', short_name: 'Wages', start_url: '/wages/', - scope: '/wages/', + scope: '/', display: 'standalone', theme_color: '#065f46', background_color: '#065f46',