Unverified Commit 5fbb1db4 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

feat(setup): add the op.console owner onboarding wizard

The /setup route redeems the one-time token from `felis setup`, then walks
the Owner through email-OTP verification and passkey enrollment before
handing off to the console. It sits outside RequireAuth — the visitor
arrives without a session and the redeem is what mints one — and is
reload-safe: a spent token resumes from the surviving session via
/auth/setup/status.

Adds the Setup page and its /setup route, the setup API client methods
(redeem/status), and the en-US/zh-CN onboarding strings.
parent 60732a62
Loading
Loading
Loading
Loading
+5 −0
Changes for panel/src/App.tsx: 5 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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. */}
          <Route path="/login" element={<Login />} />
          <Route path="/change-password" element={<ChangePassword />} />
          {/* 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. */}
          <Route path="/setup" element={<Setup />} />

          {/* Everything else requires a session. RequireAuth gates the whole app:
              no/expired session → /login, forced first-login change →
+21 −1
Changes for panel/src/i18n/resources/en-US/auth.json: 21 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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"
}
+21 −1
Changes for panel/src/i18n/resources/zh-CN/auth.json: 21 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -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": "默认通行密钥"
}
+22 −0
Changes for panel/src/lib/api.ts: 22 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -94,6 +94,19 @@ async function requestRaw<T>(
  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<SetupState>("POST", "/auth/setup/redeem", { token }),

  setupStatus: () =>
    request<SetupState>("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
+381 −0
Changes for panel/src/pages/Setup.tsx: 381 added lines, 0 removed lines.
Original line number Diff line number Diff line
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.<root>/setup?token=<raw> —
// 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<SetupState | null>(null);
  const [booting, setBooting] = useState(true);
  const [fatal, setFatal] = useState<string | null>(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 (
      <AuthLayout title="Felis">
        <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
          <Loader2 className="h-4 w-4 animate-spin" />
          {t("setup_preparing")}
        </div>
      </AuthLayout>
    );
  }

  if (fatal) {
    return (
      <AuthLayout title={t("setup_invalid_title")} subtitle={t("setup_invalid_subtitle")}>
        <Card>
          <CardContent className="space-y-4 pt-6 text-sm">
            <p className="text-muted-foreground">{fatal}</p>
            <p className="text-muted-foreground">
              {t("setup_invalid_hint_prefix")}
              <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
                felis setup
              </code>
              {t("setup_invalid_hint_suffix")}
            </p>
            <Button
              variant="outline"
              className="w-full"
              onClick={() => navigate("/login", { replace: true })}
            >
              {t("setup_goto_login")}
            </Button>
          </CardContent>
        </Card>
      </AuthLayout>
    );
  }

  // booting/fatal cover every other branch; state is set here.
  if (!state) return null;

  return (
    <AuthLayout title={t("setup_title")} subtitle={t("setup_welcome", { name: state.username })}>
      <Card>
        <CardContent className="pt-6">
          {!state.email_verified ? (
            <EmailStep initialEmail={state.email} onVerified={reload} />
          ) : !state.has_passkey ? (
            <PasskeyStep onEnrolled={reload} onSkip={() => void finish()} />
          ) : (
            <div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
              <Loader2 className="h-4 w-4 animate-spin" />
              {t("setup_entering")}
            </div>
          )}
        </CardContent>
      </Card>
    </AuthLayout>
  );
}

/** 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<void>;
}) {
  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<string | null>(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 (
    <div className="space-y-4">
      <div className="flex items-center gap-2 text-sm font-medium text-foreground">
        <Mail className="h-4 w-4 text-primary" /> {t("setup_email_step")}
      </div>
      <p className="text-sm text-muted-foreground">{t("setup_email_desc")}</p>
      {!sent ? (
        <form onSubmit={send} className="space-y-3">
          <div className="space-y-2">
            <Label htmlFor="setup-email">{t("email_address")}</Label>
            <Input
              id="setup-email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              autoComplete="email"
              autoCapitalize="none"
              autoCorrect="off"
              spellCheck={false}
              placeholder="[email protected]"
              disabled={busy}
              autoFocus
              aria-invalid={error ? true : undefined}
            />
          </div>
          {error && <p className="text-sm text-destructive">{error}</p>}
          <Button type="submit" className="w-full" disabled={busy || !email.trim()}>
            {busy ? (
              <>
                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                {t("sending_otp")}
              </>
            ) : (
              t("send_otp")
            )}
          </Button>
        </form>
      ) : (
        <form onSubmit={verify} className="space-y-3">
          <p className="text-xs text-emerald-600 dark:text-emerald-400">
            {t("setup_otp_sent", { email: email.trim() })}
          </p>
          <div className="space-y-2">
            <Label htmlFor="setup-otp">{t("otp_code")}</Label>
            <Input
              id="setup-otp"
              value={otp}
              onChange={(e) => 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}
            />
          </div>
          {error && <p className="text-sm text-destructive">{error}</p>}
          <Button type="submit" className="w-full" disabled={busy || otp.trim().length !== 6}>
            {busy ? (
              <>
                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                {t("binding")}
              </>
            ) : (
              t("setup_verify_continue")
            )}
          </Button>
          <button
            type="button"
            onClick={() => {
              setSent(false);
              setOtp("");
              setError(null);
            }}
            disabled={busy}
            className="w-full text-center text-xs text-muted-foreground hover:text-primary disabled:opacity-50"
          >
            {t("setup_change_email")}
          </button>
        </form>
      )}
    </div>
  );
}

/** 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<void>;
  onSkip: () => void;
}) {
  const { t } = useTranslation("auth");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(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 (
    <div className="space-y-4">
      <div className="flex items-center gap-2 text-sm font-medium text-foreground">
        <Fingerprint className="h-4 w-4 text-primary" /> {t("setup_passkey_step")}
      </div>
      <p className="text-sm text-muted-foreground">{t("setup_passkey_desc")}</p>
      {error && <p className="text-sm text-destructive">{error}</p>}
      <Button className="w-full gap-2" onClick={enroll} disabled={busy}>
        {busy ? (
          <>
            <Loader2 className="h-4 w-4 animate-spin" />
            {t("setup_registering")}
          </>
        ) : (
          <>
            <Fingerprint className="h-4 w-4" />
            {t("setup_create_passkey")}
          </>
        )}
      </Button>
      <button
        type="button"
        onClick={onSkip}
        disabled={busy}
        className="w-full text-center text-xs text-muted-foreground hover:text-primary disabled:opacity-50"
      >
        {t("setup_skip")}
      </button>
    </div>
  );
}