diff --git a/panel/src/App.tsx b/panel/src/App.tsx index b5f0e13..be1713e 100644 --- a/panel/src/App.tsx +++ b/panel/src/App.tsx @@ -7,6 +7,7 @@ import { RequireAuth } from "@/components/RequireAuth"; import { RequireOwner } from "@/components/RequireOwner"; import { Login } from "@/pages/Login"; import { ChangePassword } from "@/pages/ChangePassword"; +import { Setup } from "@/pages/Setup"; import { Dashboard } from "@/pages/Dashboard"; import { ServersPage } from "@/pages/servers/ServersPage"; import { ServerConsole } from "@/pages/ServerConsole"; @@ -39,6 +40,10 @@ export default function App() { they render their own centered chrome with no nav/tier dependency. */} } /> } /> + {/* Owner first-run onboarding. Like /login it sits OUTSIDE RequireAuth: + the visitor arrives from the `felis setup` link with no session, and + redeeming the one-time token is what mints one. */} + } /> {/* Everything else requires a session. RequireAuth gates the whole app: no/expired session → /login, forced first-login change → diff --git a/panel/src/i18n/resources/en-US/auth.json b/panel/src/i18n/resources/en-US/auth.json index 366bc8a..40cde61 100644 --- a/panel/src/i18n/resources/en-US/auth.json +++ b/panel/src/i18n/resources/en-US/auth.json @@ -37,5 +37,25 @@ "password_mismatch": "Passwords don't match.", "password_min_length": "At least {{min}} characters.", "change_password_btn": "Change password", - "saving": "Saving…" + "saving": "Saving…", + "setup_title": "Set up your account", + "setup_welcome": "Welcome, {{name}}", + "setup_preparing": "Preparing setup…", + "setup_entering": "Entering the console…", + "setup_invalid_title": "Can't start setup", + "setup_invalid_subtitle": "This setup link is invalid or has already been used", + "setup_invalid_hint_prefix": "Re-run ", + "setup_invalid_hint_suffix": " on the server to get a fresh setup link, or head to the sign-in page.", + "setup_goto_login": "Go to sign in", + "setup_email_step": "Step 1 · Verify email", + "setup_email_desc": "We'll email you a code — it recovers your account and is the fallback sign-in when a passkey isn't available.", + "setup_otp_sent": "Code sent to {{email}}", + "setup_verify_continue": "Verify & continue", + "setup_change_email": "Change email / resend", + "setup_passkey_step": "Step 2 · Register a passkey", + "setup_passkey_desc": "Create a passkey with your fingerprint, face, or device PIN as your primary way to sign in; the email code is the fallback.", + "setup_create_passkey": "Create passkey", + "setup_registering": "Registering…", + "setup_skip": "Skip for now — go to the console", + "setup_default_passkey_name": "Default passkey" } diff --git a/panel/src/i18n/resources/zh-CN/auth.json b/panel/src/i18n/resources/zh-CN/auth.json index d3f70e4..29ee36e 100644 --- a/panel/src/i18n/resources/zh-CN/auth.json +++ b/panel/src/i18n/resources/zh-CN/auth.json @@ -37,5 +37,25 @@ "password_mismatch": "两次输入的密码不一致。", "password_min_length": "密码至少 {{min}} 个字符。", "change_password_btn": "修改密码", - "saving": "保存中…" + "saving": "保存中…", + "setup_title": "初始化你的账户", + "setup_welcome": "欢迎,{{name}}", + "setup_preparing": "正在准备初始化…", + "setup_entering": "正在进入控制台…", + "setup_invalid_title": "无法开始设置", + "setup_invalid_subtitle": "这个设置链接无效或已被使用", + "setup_invalid_hint_prefix": "请在服务器上重新运行 ", + "setup_invalid_hint_suffix": " 获取新的设置链接,或直接前往登录页。", + "setup_goto_login": "前往登录", + "setup_email_step": "第一步 · 验证邮箱", + "setup_email_desc": "我们会向你的邮箱发送验证码,用于找回账户,也是通行密钥不可用时的备用登录方式。", + "setup_otp_sent": "验证码已发送至 {{email}}", + "setup_verify_continue": "验证并继续", + "setup_change_email": "换个邮箱 / 重新发送", + "setup_passkey_step": "第二步 · 注册通行密钥", + "setup_passkey_desc": "使用指纹、面容或设备 PIN 创建一个通行密钥,作为你登录控制台的主要方式;邮箱验证码是备用方式。", + "setup_create_passkey": "创建通行密钥", + "setup_registering": "注册中…", + "setup_skip": "暂时跳过,直接进入控制台", + "setup_default_passkey_name": "默认通行密钥" } diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 8a39d4a..8a38725 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -94,6 +94,19 @@ async function requestRaw( return parsed as T; } +// Setup bootstrap (spec §B). The one-time token from `felis setup` is redeemed for +// a lockdown session; the response (and /setup/status) reports which onboarding +// steps remain so the Setup wizard can drive email verification + passkey enrollment. +export interface SetupState { + user_id: string; + username: string; + role: string; + email: string | null; + email_verified: boolean; + has_passkey: boolean; + setup_required: boolean; +} + export const api = { // Local-password auth (spec §B1). login sets an HttpOnly session cookie as a // side effect — the panel never sees it — and returns only what to route on next @@ -139,6 +152,15 @@ export const api = { new_password, }), + // Setup bootstrap (spec §B). redeem consumes the one-time token from the setup URL + // and mints a lockdown session (Public); status re-reads progress for a reload + // mid-wizard (SetupAllowed — the surviving session, no token needed). + setupRedeem: (token: string) => + request("POST", "/auth/setup/redeem", { token }), + + setupStatus: () => + request("GET", "/auth/setup/status"), + // Identity (spec §7 GET /me) — the tier keystone. is_admin is server-computed // (Principal.IsAdmin); the panel reads it but re-deriving admin-ness is the // backend's job. Drives nav + route guards only; every admin route 403s on its diff --git a/panel/src/pages/Setup.tsx b/panel/src/pages/Setup.tsx new file mode 100644 index 0000000..a01ca1b --- /dev/null +++ b/panel/src/pages/Setup.tsx @@ -0,0 +1,381 @@ +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { Loader2, Mail, Fingerprint } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { AuthLayout } from "@/components/AuthLayout"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api, humanizeError, type SetupState } from "@/lib/api"; +import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; +import { useTier } from "@/lib/tier"; + +// Setup is the Owner's first-run onboarding wizard (spec §B setup bootstrap). The +// `felis setup` MC-bind flow prints https://op.console./setup?token= — +// the Owner is staff, so onboarding lands on the operator console, not the player +// panel; this page redeems that one-time token (minting a lockdown session), then drives the +// two remaining steps — verify email, enroll a passkey — before handing off to the +// dashboard. It sits OUTSIDE RequireAuth (like /login): the visitor arrives without +// a session, and the redeem is what creates one. +// +// Reload-safe: the token is single-use, so a refresh mid-wizard re-reads progress +// from /auth/setup/status (the surviving session) rather than dead-ending on a +// spent token. The two step endpoints and /me are all SetupAllowed, so the lockdown +// session can complete the wizard; the backend lifts the lockdown once email is +// verified, and we hand off to / once nothing remains. +export function Setup() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const { refresh } = useTier(); + const { t } = useTranslation("auth"); + + const [state, setState] = useState(null); + const [booting, setBooting] = useState(true); + const [fatal, setFatal] = useState(null); + const finishing = useRef(false); + + // Boot: redeem the URL token, or resume from the session if the token is already + // spent (a reload). No token + no session → a dead link. + useEffect(() => { + let alive = true; + (async () => { + const token = params.get("token"); + try { + let st: SetupState; + if (token) { + try { + st = await api.setupRedeem(token); + } catch (redeemErr) { + // The token may already be consumed (a reload). If a session survived, + // resume from status; otherwise surface the original redeem error. + try { + st = await api.setupStatus(); + } catch { + throw redeemErr; + } + } + } else { + st = await api.setupStatus(); + } + if (alive) setState(st); + } catch (e) { + if (alive) setFatal(humanizeError(e)); + } finally { + if (alive) setBooting(false); + } + })(); + return () => { + alive = false; + }; + }, [params]); + + // reload re-reads progress after a wizard step so the view advances to the next. + const reload = useCallback(async () => { + setState(await api.setupStatus()); + }, []); + + // finish re-reads /me (so RequireAuth sees the authenticated session) and hands + // off to the dashboard. Idempotent — a completion effect and the skip button can + // both reach here. + const finish = useCallback(async () => { + if (finishing.current) return; + finishing.current = true; + await refresh(); + navigate("/", { replace: true }); + }, [refresh, navigate]); + + // Once nothing remains (email verified AND a passkey exists, or the owner skipped + // to a backend-valid state), hand off. + useEffect(() => { + if (state && !state.setup_required) void finish(); + }, [state, finish]); + + if (booting) { + return ( + +
+ + {t("setup_preparing")} +
+
+ ); + } + + if (fatal) { + return ( + + + +

{fatal}

+

+ {t("setup_invalid_hint_prefix")} + + felis setup + + {t("setup_invalid_hint_suffix")} +

+ +
+
+
+ ); + } + + // booting/fatal cover every other branch; state is set here. + if (!state) return null; + + return ( + + + + {!state.email_verified ? ( + + ) : !state.has_passkey ? ( + void finish()} /> + ) : ( +
+ + {t("setup_entering")} +
+ )} +
+
+
+ ); +} + +/** EmailStep is the §B email-OTP step: send a code, then verify it. On success it + * calls onVerified (a status re-read) so the wizard advances to the passkey step. + * Mirrors the Account page's email card against the same SetupAllowed endpoints. */ +function EmailStep({ + initialEmail, + onVerified, +}: { + initialEmail: string | null; + onVerified: () => Promise; +}) { + const { t } = useTranslation("auth"); + const [email, setEmail] = useState(initialEmail ?? ""); + const [otp, setOtp] = useState(""); + const [sent, setSent] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function send(e: FormEvent) { + e.preventDefault(); + const addr = email.trim(); + if (!addr || busy) return; + setBusy(true); + setError(null); + try { + await api.emailStart(addr); + setSent(true); + } catch (err) { + setError(humanizeError(err)); + } finally { + setBusy(false); + } + } + + async function verify(e: FormEvent) { + e.preventDefault(); + const code = otp.trim(); + if (!code || busy) return; + setBusy(true); + setError(null); + try { + await api.emailVerify(code); + await onVerified(); // advances (unmounts this step) — no need to clear busy + } catch (err) { + setError(humanizeError(err)); + setBusy(false); + } + } + + return ( +
+
+ {t("setup_email_step")} +
+

{t("setup_email_desc")}

+ {!sent ? ( +
+
+ + setEmail(e.target.value)} + autoComplete="email" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + placeholder="you@example.com" + disabled={busy} + autoFocus + aria-invalid={error ? true : undefined} + /> +
+ {error &&

{error}

} + +
+ ) : ( +
+

+ {t("setup_otp_sent", { email: email.trim() })} +

+
+ + setOtp(e.target.value)} + inputMode="numeric" + autoComplete="one-time-code" + maxLength={6} + className="font-mono text-center tracking-[0.3em]" + disabled={busy} + autoFocus + aria-invalid={error ? true : undefined} + /> +
+ {error &&

{error}

} + + +
+ )} +
+ ); +} + +/** PasskeyStep enrolls the Owner's first passkey against the SetupAllowed register + * endpoints — the same ceremony as the Account page. Email is already verified at + * this point (backend lockdown lifted), so "skip" is a safe escape if the + * authenticator misbehaves: the owner lands in the console and can enroll later. */ +function PasskeyStep({ + onEnrolled, + onSkip, +}: { + onEnrolled: () => Promise; + onSkip: () => void; +}) { + const { t } = useTranslation("auth"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function enroll() { + if (busy) return; + setBusy(true); + setError(null); + try { + const options = await api.passkeyRegisterBegin(); + const publicKey: PublicKeyCredentialCreationOptions = { + ...options, + challenge: base64urlToBytes(options.challenge), + user: { + ...options.user, + id: base64urlToBytes(options.user.id), + }, + excludeCredentials: options.excludeCredentials?.map((cred: any) => ({ + ...cred, + id: base64urlToBytes(cred.id), + })), + }; + + const credential = (await navigator.credentials.create({ + publicKey, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error("Failed to create credential"); + } + + const response = credential.response as AuthenticatorAttestationResponse; + const attestation = { + id: credential.id, + rawId: bytesToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bytesToBase64url(response.clientDataJSON), + attestationObject: bytesToBase64url(response.attestationObject), + transports: + typeof response.getTransports === "function" ? response.getTransports() : [], + }, + }; + + await api.passkeyRegisterFinish(t("setup_default_passkey_name"), attestation); + await onEnrolled(); + } catch (err: any) { + setError(humanizeError(err)); + setBusy(false); + } + } + + return ( +
+
+ {t("setup_passkey_step")} +
+

{t("setup_passkey_desc")}

+ {error &&

{error}

} + + +
+ ); +}