Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.
Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.
Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).
Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.
Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.
fixes #286
refs #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
6.1 KiB
TypeScript
182 lines
6.1 KiB
TypeScript
import { createContext, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { RefreshCw, TriangleAlert } from 'lucide-react';
|
|
import { Button, Card, Spinner } from './components';
|
|
import {
|
|
clearLegacyStoredApiKey,
|
|
getAuthConfig,
|
|
getAuthSession,
|
|
type AuthConfig,
|
|
type AuthSession
|
|
} from './api';
|
|
import { LoginScreen } from './screens/LoginScreen';
|
|
import { SetupScreen } from './screens/SetupScreen';
|
|
|
|
// The boot gate (#295). It wraps <App/> OUTSIDE the shell/router, so Login and Setup mint no URLs and a
|
|
// deep link survives an intervening sign-in. On mount it asks the PUBLIC `/api/v1/auth/config`, then (unless
|
|
// setup is required) `/api/v1/auth/session`, and renders Setup / Login / the app accordingly. A config-fetch
|
|
// failure lands on an explicit error state with Retry — a keyless, sessionless browser must ALWAYS be able
|
|
// to reach the login form, never a blank screen.
|
|
|
|
export interface AuthContextValue {
|
|
username: string | null;
|
|
method: string | null;
|
|
/** Return to the login screen after the caller has POSTed `/api/v1/auth/logout`. */
|
|
signOut: () => void;
|
|
/** Flip the gate to the login screen (e.g. the 401 banner's "Sign in"). */
|
|
requireLogin: () => void;
|
|
}
|
|
|
|
// A safe default so consumers (App, UserMenu, UnauthorizedBanner) render without a provider — e.g.
|
|
// `App.test.tsx` renders <App/> directly. AuthGate publishes the real value only in the `ready` state.
|
|
// Colocated with the gate that owns it (per the #295 plan); the fast-refresh rule is disabled to allow
|
|
// this non-component export alongside AuthGate, matching LocalLibraryEditScreen's established pattern.
|
|
// eslint-disable-next-line react-refresh/only-export-components
|
|
export const AuthContext = createContext<AuthContextValue>({
|
|
username: null,
|
|
method: null,
|
|
signOut: () => {},
|
|
requireLogin: () => {}
|
|
});
|
|
|
|
type GateState =
|
|
| { status: 'checking' }
|
|
| { status: 'error' }
|
|
| { status: 'setup'; config: AuthConfig }
|
|
| { status: 'login'; config: AuthConfig }
|
|
| { status: 'ready'; session: AuthSession };
|
|
|
|
export function AuthGate({ children }: { children: ReactNode }) {
|
|
const [state, setState] = useState<GateState>({ status: 'checking' });
|
|
const activeRef = useRef(true);
|
|
const seqRef = useRef(0);
|
|
const configRef = useRef<AuthConfig | null>(null);
|
|
const clearedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
// Mount/unmount tracker — guards against the StrictMode double-mount and a slow fetch resolving
|
|
// after unmount (spa-conventions §3). Re-arm on (re)mount so the second StrictMode pass is live.
|
|
activeRef.current = true;
|
|
return () => {
|
|
activeRef.current = false;
|
|
};
|
|
}, []);
|
|
|
|
// Runs the boot fetch, setting state ONLY in the async completions (never synchronously) so it's safe
|
|
// to invoke from the mount effect without tripping the react-hooks "no set-state-in-effect" rule (§3):
|
|
// the initial `checking` state is the useState default, so the effect path needs no synchronous reset.
|
|
const runGate = useCallback(() => {
|
|
const seq = ++seqRef.current;
|
|
|
|
getAuthConfig()
|
|
.then((config) => {
|
|
if (!activeRef.current || seq !== seqRef.current) {
|
|
return undefined;
|
|
}
|
|
|
|
configRef.current = config;
|
|
|
|
if (config.setupRequired) {
|
|
setState({ status: 'setup', config });
|
|
return undefined;
|
|
}
|
|
|
|
return getAuthSession().then((session) => {
|
|
if (!activeRef.current || seq !== seqRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (session.authenticated) {
|
|
setState({ status: 'ready', session });
|
|
} else {
|
|
setState({ status: 'login', config });
|
|
}
|
|
});
|
|
})
|
|
.catch(() => {
|
|
if (!activeRef.current || seq !== seqRef.current) {
|
|
return;
|
|
}
|
|
setState({ status: 'error' });
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
runGate();
|
|
}, [runGate]);
|
|
|
|
// Re-run the gate from an event handler (Retry / recovery), resetting to the checking state first.
|
|
const restart = useCallback(() => {
|
|
setState({ status: 'checking' });
|
|
runGate();
|
|
}, [runGate]);
|
|
|
|
useEffect(() => {
|
|
// Reaching the app once means any pre-session `ctv-api-key` is now dead — clear it a single time.
|
|
if (state.status === 'ready' && !clearedRef.current) {
|
|
clearedRef.current = true;
|
|
clearLegacyStoredApiKey();
|
|
}
|
|
}, [state.status]);
|
|
|
|
const onAuthenticated = useCallback((session: AuthSession) => {
|
|
setState({ status: 'ready', session });
|
|
}, []);
|
|
|
|
const requireLogin = useCallback(() => {
|
|
const config = configRef.current;
|
|
if (config) {
|
|
setState({ status: 'login', config });
|
|
} else {
|
|
// No config cached (shouldn't happen post-boot) — re-run the whole gate to recover safely.
|
|
restart();
|
|
}
|
|
}, [restart]);
|
|
|
|
if (state.status === 'checking') {
|
|
return (
|
|
<div className="ctv-auth-page" role="status" aria-label="Checking sign-in status">
|
|
<Spinner size={28} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (state.status === 'error') {
|
|
return (
|
|
<div className="ctv-auth-page">
|
|
<Card padded style={{ maxWidth: 420, width: '100%' }} title="Can't reach the server">
|
|
<div className="ctv-settings-warn-callout" role="alert">
|
|
<TriangleAlert aria-hidden="true" color="var(--status-warn)" size={15} />
|
|
<span>Couldn't load the sign-in configuration. Check that the server is running, then retry.</span>
|
|
</div>
|
|
<div style={{ marginTop: 16 }}>
|
|
<Button onClick={restart} startIcon={<RefreshCw aria-hidden="true" size={14} />} variant="primary">
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (state.status === 'setup') {
|
|
return <SetupScreen onAuthenticated={onAuthenticated} />;
|
|
}
|
|
|
|
if (state.status === 'login') {
|
|
return <LoginScreen config={state.config} onAuthenticated={onAuthenticated} />;
|
|
}
|
|
|
|
return (
|
|
<AuthContext.Provider
|
|
value={{
|
|
username: state.session.username ?? null,
|
|
method: state.session.method ?? null,
|
|
signOut: requireLogin,
|
|
requireLogin
|
|
}}
|
|
>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
);
|
|
}
|