Files
Felis/panel/src/lib/tier.tsx
T
flyemoji 7860152f57 feat(auth)!: go fully passwordless and fix cross-check review findings
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.
2026-07-20 04:47:32 +09:00

103 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}