feat(web-ui): AuthGate orchestration + replace 401 bounce with in-SPA re-render
This commit is contained in:
parent
96effaaa3c
commit
7e240d86c8
5 changed files with 96 additions and 11 deletions
|
|
@ -154,4 +154,5 @@ function lighten(hex, amt) {
|
|||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
const AuthGate = window.AuthGateComponent;
|
||||
root.render(<AuthGate><App /></AuthGate>);
|
||||
|
|
|
|||
77
services/web-ui/public/auth-gate.jsx
Normal file
77
services/web-ui/public/auth-gate.jsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// auth-gate.jsx — owns the "logged in or not" state.
|
||||
//
|
||||
// The SPA boots into <AuthGate>, which calls GET /auth/me. On 401 it then
|
||||
// calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen>
|
||||
// (defined in screens-auth.jsx, Task 16). On 200 it renders the real <App>.
|
||||
//
|
||||
// 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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: 'var(--bg-0)' }}>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-3)' }}>Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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;
|
||||
})();
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@
|
|||
<script src="dist/icons.js"></script>
|
||||
<script src="dist/visuals.js"></script>
|
||||
<script src="dist/shell.js"></script>
|
||||
<script src="dist/auth-gate.js"></script>
|
||||
<script src="dist/screens-auth.js"></script>
|
||||
<script src="dist/screens-home.js"></script>
|
||||
<script src="dist/screens-library.js"></script>
|
||||
<script src="dist/screens-asset.js"></script>
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
|
|||
<button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out"
|
||||
onClick={async () => {
|
||||
try { await window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' }); } catch (_) {}
|
||||
window.location.replace('/login.html');
|
||||
window.AuthGate.bounce('user signed out');
|
||||
}}>
|
||||
<Icon name="power" />
|
||||
</button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue