From 7e240d86c81fde4538a663997b4fe0dd4eaeb408 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 15:08:14 -0400 Subject: [PATCH] feat(web-ui): AuthGate orchestration + replace 401 bounce with in-SPA re-render --- services/web-ui/public/app.jsx | 3 +- services/web-ui/public/auth-gate.jsx | 77 ++++++++++++++++++++++++++++ services/web-ui/public/data.jsx | 23 +++++---- services/web-ui/public/index.html | 2 + services/web-ui/public/shell.jsx | 2 +- 5 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 services/web-ui/public/auth-gate.jsx diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index 06e9121..93c7040 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -154,4 +154,5 @@ function lighten(hex, amt) { } const root = ReactDOM.createRoot(document.getElementById('root')); -root.render(); +const AuthGate = window.AuthGateComponent; +root.render(); diff --git a/services/web-ui/public/auth-gate.jsx b/services/web-ui/public/auth-gate.jsx new file mode 100644 index 0000000..2e9a0ca --- /dev/null +++ b/services/web-ui/public/auth-gate.jsx @@ -0,0 +1,77 @@ +// auth-gate.jsx — owns the "logged in or not" state. +// +// The SPA boots into , which calls GET /auth/me. On 401 it then +// calls GET /auth/setup-required and renders or +// (defined in screens-auth.jsx, Task 16). On 200 it renders the real . +// +// This component is the SINGLE source of truth for the auth check — no other +// component should redirect to a login page or wipe data on 401. Other code +// surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts +// the gate so the next /auth/me request decides what to do. + +(function () { + const API = '/api/v1'; + const LAST_PATH_KEY = 'df.auth.last_path'; + + async function authFetch(path, opts) { + return fetch(API + path, { + credentials: 'include', + ...opts, + headers: { + ...(opts && opts.headers), + 'Content-Type': 'application/json', + ...((opts && opts.method && opts.method !== 'GET') ? { 'X-Requested-With': 'dragonflight-ui' } : {}), + }, + }); + } + + function AuthGate({ children }) { + const [state, setState] = React.useState({ kind: 'loading' }); + + const check = React.useCallback(async () => { + setState({ kind: 'loading' }); + try { + const r = await authFetch('/auth/me'); + if (r.status === 200) { + const me = await r.json(); + window.ZAMPP_DATA = window.ZAMPP_DATA || {}; + window.ZAMPP_DATA.ME = me; + setState({ kind: 'authed' }); + return; + } + } catch (_) { /* fall through to setup/login decision */ } + const setup = await authFetch('/auth/setup-required').then(r => r.json()).catch(() => ({ required: false })); + setState({ kind: setup.required ? 'setup' : 'login' }); + }, []); + + React.useEffect(() => { check(); }, [check]); + + // Global hook: anything else (e.g. data.jsx 401 handler) can re-trigger + // the gate after auth state changes server-side. Saves current path so + // the user is restored to where they were after login. + React.useEffect(() => { + window.AuthGate = { + bounce(reason) { + try { sessionStorage.setItem(LAST_PATH_KEY, location.pathname + location.search); } catch {} + if (reason) console.warn('[AuthGate] bounce:', reason); + check(); + }, + signedIn() { check(); }, + }; + }, [check]); + + if (state.kind === 'loading') { + return ( +
+
Loading…
+
+ ); + } + if (state.kind === 'setup') return React.createElement(window.SetupScreen, { onDone: () => window.AuthGate.signedIn() }); + if (state.kind === 'login') return React.createElement(window.LoginScreen, { onDone: () => window.AuthGate.signedIn() }); + return children; + } + + window.AuthGate = window.AuthGate || {}; + window.AuthGateComponent = AuthGate; +})(); diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index 7842508..af3900c 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -62,19 +62,24 @@ window.ZAMPP_DATA = { }; async function apiFetch(path, opts = {}) { + const method = (opts.method || 'GET').toUpperCase(); + const headers = { + ...(opts.headers || {}), + 'Content-Type': 'application/json', + }; + if (method !== 'GET' && method !== 'HEAD') headers['X-Requested-With'] = 'dragonflight-ui'; + const res = await fetch(API + path, { credentials: 'include', ...opts, - headers: { ...(opts.headers || {}), 'Content-Type': 'application/json' }, + headers, }); - // 401 from any API call means there's no live session. Bounce to the - // login screen instead of leaving the app in a half-loaded state. - // While AUTH_ENABLED=false the server returns a synthetic /auth/me with - // 200 so this branch never fires; flipping AUTH_ENABLED=true is what - // activates the redirect end-to-end. - if (res.status === 401 && !location.pathname.endsWith('/login.html')) { - location.replace('/login.html'); - throw new Error('Unauthenticated — redirecting to login'); + // 401: hand off to AuthGate, which will re-render Login (no full-page reload). + if (res.status === 401) { + if (window.AuthGate && typeof window.AuthGate.bounce === 'function') { + window.AuthGate.bounce('apiFetch saw 401 on ' + path); + } + throw new Error('Unauthenticated'); } if (!res.ok) throw new Error(res.status + ' ' + res.statusText); return res.json(); diff --git a/services/web-ui/public/index.html b/services/web-ui/public/index.html index 5c8927e..cab074c 100644 --- a/services/web-ui/public/index.html +++ b/services/web-ui/public/index.html @@ -25,6 +25,8 @@ + + diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx index 812cd8e..9ec2591 100644 --- a/services/web-ui/public/shell.jsx +++ b/services/web-ui/public/shell.jsx @@ -215,7 +215,7 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {