Remove password authentication everywhere; the only session doors are passkey (WebAuthn), email OTP, in-game bind codes, QR scan-login, and op-login vouching. Remediates the 33-finding cross-check review across backend, CLI, panel, plugins, and docs. Backend/CLI: - Drop password routes and fields from account/user/onboard/auth handlers; align tests (new account subtests, naming reserves "console", op-login/onboard/qr-login test updates). - Add migrations 0016_op_login.sql and 0017_drop_password.sql. - Thread panel/admin hostnames from hostcfg through api.go, setup_panel.go, tui_root.go and tui_preflight.go instead of hardcoding; bootstrap.sh writes panel-hostname/admin-hostname into felis.toml. - Reword breakglass and TUI copy for passwordless flows. Panel: - Delete the ChangePassword page and all password UI; align login/auth/api/types with the passwordless contract; add the migration and op-login approval flows. - i18n: convert ImageBuildPage durations/status badges and ServerLuckPerms strings to translation keys; drop 72 orphan keys per locale; unify the title as "Felis - Console". Plugins (all six rebuilt): - Velocity waiting router returns 503 at_capacity during wake; MOTD/control-channel copy and config comments. - Paper zh menu title; Limbo bind-code TTL 600s with panel_url preference; unified /link lines in fabric/forge/neoforge; shared link-client javadoc contract fixes. Docs: openapi.yaml, sequence-diagrams.md, deploy/limbo/README.md and plugins/README.md aligned with the implementation. BREAKING CHANGE: migration 0017 irreversibly drops users.password_hash and users.must_change_password; password login cannot be restored after migrating.
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
import {
|
||
createContext,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type ReactNode,
|
||
} from "react";
|
||
import type { Identity } from "./types";
|
||
import { api } from "./api";
|
||
import { deriveAuth, type AuthState } from "./auth";
|
||
|
||
// TierProvider fetches GET /me at boot and re-fetches on demand (refresh), exposing
|
||
// the result through context. Three design rules, all load-bearing:
|
||
//
|
||
// 1. Fail-closed: `isAdmin` is `identity?.is_admin === true`. While /me is in
|
||
// flight (`identity === null`) or after it rejects, isAdmin is false — admin
|
||
// nav/routes stay hidden. The `=== true` (not just truthy) also closes the
|
||
// wire-shape trap: if the JSON ever arrives without `is_admin`, the value is
|
||
// `undefined`, which is correctly non-admin rather than a thrown access.
|
||
//
|
||
// 2. Still-functional on a transient failure: a /me rejection that is NOT a 401 is
|
||
// swallowed to a null identity, NOT re-thrown and NOT treated as logged-out. A
|
||
// principal whose /me momentarily fails still gets the full User-Side app; they
|
||
// simply don't see admin surfaces. (The backend 403s admin data calls
|
||
// independently, so this is safe.) Only a genuine 401 sets `unauthenticated`.
|
||
//
|
||
// 3. Login-aware: `unauthenticated` (a true 401) routes to /login; `refresh()`
|
||
// re-reads /me after a login / logout so the gate re-evaluates without a reload.
|
||
//
|
||
// Rules 1–2 are UX truth, not a security control — see DESIGN-WEB-3SIDES §1.
|
||
|
||
export interface TierState extends AuthState {
|
||
/** Owner (platform-level, one above admin) — exposed so nav can show the Users
|
||
* section only to the owner identity. Computed server-side, fail-closed. */
|
||
isOwner: boolean;
|
||
/** Re-fetch /me and recompute the auth state. Awaitable so callers can sequence a
|
||
* navigation after the context has settled (login → refresh → redirect). */
|
||
refresh: () => Promise<void>;
|
||
}
|
||
|
||
const TierContext = createContext<TierState>({
|
||
identity: null,
|
||
loading: true,
|
||
isAdmin: false,
|
||
isOwner: false,
|
||
unauthenticated: false,
|
||
refresh: async () => {},
|
||
});
|
||
|
||
export function TierProvider({ children }: { children: ReactNode }) {
|
||
const [identity, setIdentity] = useState<Identity | null>(null);
|
||
const [error, setError] = useState<unknown>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
// A monotonic ticket guards against out-of-order refreshes (e.g. a logout's /me
|
||
// resolving after a subsequent login's): only the latest call commits its result.
|
||
const seq = useRef(0);
|
||
|
||
const refresh = useCallback(async () => {
|
||
const ticket = ++seq.current;
|
||
setLoading(true);
|
||
try {
|
||
const id = await api.me();
|
||
if (ticket === seq.current) {
|
||
setIdentity(id);
|
||
setError(null);
|
||
}
|
||
} catch (e) {
|
||
// Keep the error so deriveAuth can tell a 401 (→ login) from a transient
|
||
// failure (→ stay functional). Identity is cleared either way.
|
||
if (ticket === seq.current) {
|
||
setIdentity(null);
|
||
setError(e);
|
||
}
|
||
} finally {
|
||
if (ticket === seq.current) setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
const state = deriveAuth(identity, error, loading);
|
||
// isOwner is separate from isAdmin: an owner implicitly passes isAdmin (the
|
||
// backend grades by operation), but isOwner gates user management. Both are
|
||
// fail-closed — identity === null or missing fields → false.
|
||
const isOwner = identity?.is_owner === true;
|
||
|
||
return (
|
||
<TierContext.Provider value={{ ...state, isOwner, refresh }}>
|
||
{children}
|
||
</TierContext.Provider>
|
||
);
|
||
}
|
||
|
||
/** useTier reads the current identity/tier/auth state. */
|
||
export function useTier(): TierState {
|
||
return useContext(TierContext);
|
||
}
|