Login feature flag added.
This commit is contained in:
1 parent
525e8e9718
commit
71d1730284
8 files changed
+91
-26
No files matched your search
+3
-1
@@ -215,7 +215,9 @@ export function App() {
|
||||
</p>
|
||||
</div>
|
||||
{/* Session UI (M4). Anonymous-first: logged out, this is only an
|
||||
unobtrusive "Sign in" button — everything else is unchanged. */}
|
||||
unobtrusive "Sign in" button — everything else is unchanged.
|
||||
Renders nothing until the /me probe confirms the server has auth
|
||||
enabled (runtime flag, D-015). */}
|
||||
<AuthForms />
|
||||
</header>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ type Mode = "login" | "register";
|
||||
* stays authoritative: its `{ error }` messages are shown verbatim on 4xx.
|
||||
*/
|
||||
export function AuthForms() {
|
||||
const { user, login, register, logout } = useAuth();
|
||||
const { enabled, user, login, register, logout } = useAuth();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<Mode>("login");
|
||||
@@ -86,6 +86,10 @@ export function AuthForms() {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
}
|
||||
|
||||
// Runtime feature flag (D-015): until the /me probe confirms the server
|
||||
// exposes /api/auth/*, this deployment has no login — render nothing.
|
||||
if (!enabled) return null;
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="auth">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join, normalize, sep } from "node:path";
|
||||
import { ensureIcons, ICONS_DIR } from "../scripts/generate-icons";
|
||||
import { injectPrecacheManifest } from "./pwa/precache";
|
||||
import { auth } from "./server/auth";
|
||||
import { AUTH_ENABLED } from "./server/flags";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const IS_PRODUCTION = process.env.NODE_ENV === "production";
|
||||
@@ -56,6 +57,9 @@ async function serveOrtFile(dir: string, name: string): Promise<Response> {
|
||||
|
||||
async function handleApiRequest(request: Request): Promise<Response> {
|
||||
const { pathname } = new URL(request.url);
|
||||
// Runtime feature flag (D-015): with auth disabled, the endpoints don't
|
||||
// exist — the client's /me probe reads the 404 as "feature off".
|
||||
if (!AUTH_ENABLED && pathname.startsWith("/api/auth/")) return NOT_FOUND();
|
||||
switch (`${request.method} ${pathname}`) {
|
||||
case "POST /api/auth/register":
|
||||
return auth.register(request);
|
||||
|
||||
+24
-9
@@ -134,26 +134,41 @@ export function register(
|
||||
return requestUser(fetchFn, "/api/auth/register", { username, password });
|
||||
}
|
||||
|
||||
/** Result of the startup /me probe (fetchAuthStatus). */
|
||||
export interface AuthStatus {
|
||||
/**
|
||||
* Whether this deployment offers login at all. False when the server 404s
|
||||
* /api/auth/me (runtime feature flag AUTH_ENABLED=false, D-015) — and also
|
||||
* while the server is unreachable, since signing in is impossible then
|
||||
* anyway. The auth UI renders nothing until this is true.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/** The signed-in user, or null when anonymous. */
|
||||
user: AuthUser | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/auth/me → the current user, or null when anonymous. NEVER rejects:
|
||||
* GET /api/auth/me → auth availability + the current user. NEVER rejects:
|
||||
* the app is offline-first and /api/* is network-only in the service worker,
|
||||
* so 401s, network failures, and malformed bodies all resolve to anonymous
|
||||
* without surfacing an error (Architecture.md §5 anonymous-first invariant).
|
||||
* so 401s and malformed bodies resolve to anonymous, and 404 (feature flag
|
||||
* off, D-015) and network failure resolve to `enabled: false` — none of them
|
||||
* surface an error (Architecture.md §5 anonymous-first invariant).
|
||||
*/
|
||||
export async function fetchCurrentUser(
|
||||
export async function fetchAuthStatus(
|
||||
fetchFn: FetchLike = defaultFetch,
|
||||
): Promise<AuthUser | null> {
|
||||
): Promise<AuthStatus> {
|
||||
try {
|
||||
const response = await fetchFn("/api/auth/me", {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload: unknown = await response.json();
|
||||
if (response.status === 404) return { enabled: false, user: null };
|
||||
if (!response.ok) return { enabled: true, user: null };
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
const user = (payload as { user?: unknown } | null)?.user;
|
||||
return isAuthUser(user) ? user : null;
|
||||
return { enabled: true, user: isAuthUser(user) ? user : null };
|
||||
} catch {
|
||||
return null;
|
||||
return { enabled: false, user: null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-6
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
fetchCurrentUser,
|
||||
fetchAuthStatus,
|
||||
login as loginRequest,
|
||||
logout as logoutRequest,
|
||||
register as registerRequest,
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
} from "./authClient";
|
||||
|
||||
export interface AuthState {
|
||||
/**
|
||||
* Whether this deployment offers login at all (runtime flag, D-015).
|
||||
* Starts false — the auth UI stays hidden until the /me probe confirms the
|
||||
* endpoints exist, so a disabled deployment never flashes a Sign in button.
|
||||
*/
|
||||
enabled: boolean;
|
||||
/** The signed-in user, or null while anonymous (the default). */
|
||||
user: AuthUser | null;
|
||||
/** Rejects with a UI-safe Error message on failure (e.g. 401). */
|
||||
@@ -21,18 +27,22 @@ export interface AuthState {
|
||||
/**
|
||||
* Session state for the M4 auth UI. The session cookie is HttpOnly, so the
|
||||
* only source of truth is GET /api/auth/me, probed once on mount.
|
||||
* `fetchCurrentUser` never rejects — 401 and network failure (offline, server
|
||||
* down) both resolve to anonymous, keeping the anonymous-first invariant:
|
||||
* `fetchAuthStatus` never rejects — 401 resolves to anonymous, and 404
|
||||
* (AUTH_ENABLED=false, D-015) or network failure (offline, server down)
|
||||
* resolve to `enabled: false`, keeping the anonymous-first invariant:
|
||||
* a failing /me must not surface any error.
|
||||
*/
|
||||
export function useAuth(): AuthState {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchCurrentUser().then((current) => {
|
||||
fetchAuthStatus().then((status) => {
|
||||
// Guard against StrictMode double-mounts / fast unmounts.
|
||||
if (!cancelled && current) setUser(current);
|
||||
if (cancelled) return;
|
||||
setEnabled(status.enabled);
|
||||
if (status.user) setUser(status.user);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -53,5 +63,5 @@ export function useAuth(): AuthState {
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return { user, login, register, logout };
|
||||
return { enabled, user, login, register, logout };
|
||||
}
|
||||
Reference in new issue
Block a user