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 (
+
+ );
+ }
+ 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 }) {