diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 9d6c373..1d76365 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3317,6 +3317,46 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } + /api/v1/account/email: + post: + tags: [account] + operationId: setEmail + summary: Record the caller's email WITHOUT verifying it (setup bootstrap, spec §B2). + description: > + Writes the supplied address to the authenticated principal's user row and + clears email_verified (already false for a fresh Owner). The setup bootstrap + has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP + flow proves control of the address via /account/email/verify. + x-felis-face: [external] + x-felis-tier: app + security: [{ accessJWT: [] }] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + responses: + '200': + description: Email recorded (unverified). + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + '400': + description: A valid email is required. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/account/passkey/register/begin: post: tags: [account] diff --git a/internal/api/api.go b/internal/api/api.go index f8d2645..35288cf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -391,6 +391,10 @@ func (a *API) externalAPIRoutes() []apiRoute { // email is an ordinary authenticated operation, scoped to the principal. {Method: "POST", Pattern: "/api/v1/account/email/start", SetupAllowed: true, h: a.handleEmailOTPStart}, {Method: "POST", Pattern: "/api/v1/account/email/verify", SetupAllowed: true, h: a.handleEmailOTPVerify}, + // Record-only email: the setup wizard's Step 1 stores the Owner's address + // UNVERIFIED (no SMTP at bootstrap ⇒ no code to mail). email_verified stays + // false until a later Settings/SMTP flow proves control via /email/verify above. + {Method: "POST", Pattern: "/api/v1/account/email", SetupAllowed: true, h: a.handleSetEmail}, // Passkey enrollment (spec §14 WebAuthn / Phase 6 bind), web side: /register/begin // mints a credential-creation challenge for the caller, /register/finish verifies // the authenticator's attestation and binds the passkey, and the credentials @@ -522,7 +526,7 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler // explicitly opts out. The wrapper is nil-principal safe, so it is inert on // the internal face (service-token callers carry no Principal). if !rt.SetupAllowed { - h = a.requireEmailVerified(h) + h = a.requireOnboarded(h) } auth.HandleFunc(pattern, h) } @@ -542,20 +546,31 @@ func (a *API) baseChain(h http.Handler) http.Handler { return withRequestID(withRecover(h)) } -// requireEmailVerified fences an authenticated route behind the setup-lockdown: -// a session whose EmailVerified is false (a freshly-onboarded principal that has -// not yet proved control of its email) is restricted to SetupAllowed routes only. -// The wrapper is nil-principal safe, so it is inert on the internal face -// (service-token callers carry no Principal) and on the external face's admin -// Zero-Trust paths (those carry an IsAdmin/IsOwner principal that has already -// passed email verification at account creation). -func (a *API) requireEmailVerified(h http.HandlerFunc) http.HandlerFunc { +// requireOnboarded fences an authenticated route behind the setup-lockdown: a +// freshly-onboarded principal that has not finished setup is restricted to +// SetupAllowed routes only. The lockdown lifts on a durable login credential, NOT +// on email verification: the bootstrap Owner has no verified email (no SMTP exists +// at bootstrap) and a passkey is the ONLY credential that logs the Owner in +// pre-SMTP (email-OTP login refuses admin accounts; op-login needs SMTP + a second +// admin). So passkey enrollment is what completes setup — and it must, or the +// unverified Owner could never reach the Settings page to configure SMTP. +// +// Only a session principal whose email is still unverified reaches the passkey +// lookup; after setup that is just the bootstrap Owner, so the extra query is not +// on any hot path. The wrapper is nil-principal safe, so it is inert on the +// internal face (service-token callers carry no Principal) and on the external +// face's admin Zero-Trust paths (those carry an IsAdmin/IsOwner principal that has +// already passed email verification at account creation). +func (a *API) requireOnboarded(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) if p != nil && p.ViaSession && !p.EmailVerified { - writeError(w, r, newError(http.StatusForbidden, "setup_required", - "email verification is required before this action is available")) - return + creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID) + if len(creds) == 0 { + writeError(w, r, newError(http.StatusForbidden, "setup_required", + "passkey enrollment is required before this action is available")) + return + } } h(w, r) } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index dc4f3b9..8677ac8 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -359,6 +359,16 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s } return live.email, nil } +func (f *fakeRepo) SetUserEmail(_ context.Context, userID, email string) error { + for _, u := range f.staff { // record + clear verified (proves nothing) — mirrors PGRepo + if u.ID == userID { + u.Email = email + u.EmailVerified = false + return nil + } + } + return ErrNotFound +} // CreatePasskeyChallenge / ConsumePasskeyChallengeByUser mirror PGRepo's contract so // the hermetic tests exercise the same semantics: a fresh begin supersedes ALL prior diff --git a/internal/api/handlers_email_otp.go b/internal/api/handlers_email_otp.go index 81bf074..2c37ffc 100644 --- a/internal/api/handlers_email_otp.go +++ b/internal/api/handlers_email_otp.go @@ -230,6 +230,42 @@ func (a *API) deliverOTP(ctx context.Context, email, code string) error { return a.Mailer.SendOTP(ctx, email, code) } +// setEmailRequest is the record-email body: the address to bind to the caller's +// account WITHOUT an OTP round-trip. +type setEmailRequest struct { + Email string `json:"email"` +} + +// handleSetEmail records the caller's email without verifying it (SetupAllowed). The +// setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; the +// address is stored unverified and a later Settings/SMTP flow proves control of it. +// This is the setup wizard's Step-1 write. The OTP start/verify pair above is left +// intact for the Account page and for post-SMTP verification — this door deliberately +// does NOT touch email_verified. +func (a *API) handleSetEmail(w http.ResponseWriter, r *http.Request) { + p := principalFromContext(r.Context()) + if err := requireJSONContentType(r); err != nil { + writeError(w, r, err) + return + } + var req setEmailRequest + if err := decodeJSON(w, r, &req); err != nil { + writeError(w, r, err) + return + } + email := strings.TrimSpace(req.Email) + if !looksLikeEmail(email) { + writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required")) + return + } + if err := a.Repo.SetUserEmail(r.Context(), p.UserID, email); err != nil { + writeError(w, r, err) + return + } + a.audit(r, auditActor(p), "account.email.set", "") + writeJSON(w, http.StatusOK, map[string]any{"email": email}) +} + // auditActor picks the most identifying actor string for a principal: the audited // Access email when present, else the stable user id. A player mid-onboarding may // not have a verified email yet, so the id keeps the audit row attributable. diff --git a/internal/api/handlers_setup.go b/internal/api/handlers_setup.go index 17350c4..6e36484 100644 --- a/internal/api/handlers_setup.go +++ b/internal/api/handlers_setup.go @@ -104,7 +104,11 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) { "email": u.Email, "email_verified": u.EmailVerified, "has_passkey": hasPasskey, - "setup_required": !u.EmailVerified || !hasPasskey, + // Setup completes on email recorded + passkey enrolled. NOT email_verified: + // the bootstrap has no SMTP, so the Owner's address is stored unverified and a + // later Settings/SMTP flow verifies it. Passkey is the Owner's only pre-SMTP + // login credential, so it — not email verification — is the durable gate. + "setup_required": u.Email == "" || !hasPasskey, }) } @@ -131,6 +135,10 @@ func (a *API) handleSetupStatus(w http.ResponseWriter, r *http.Request) { "email": u.Email, "email_verified": u.EmailVerified, "has_passkey": hasPasskey, - "setup_required": !u.EmailVerified || !hasPasskey, + // Setup completes on email recorded + passkey enrolled. NOT email_verified: + // the bootstrap has no SMTP, so the Owner's address is stored unverified and a + // later Settings/SMTP flow verifies it. Passkey is the Owner's only pre-SMTP + // login credential, so it — not email verification — is the durable gate. + "setup_required": u.Email == "" || !hasPasskey, }) } diff --git a/internal/api/handlers_setup_test.go b/internal/api/handlers_setup_test.go new file mode 100644 index 0000000..432faf5 --- /dev/null +++ b/internal/api/handlers_setup_test.go @@ -0,0 +1,106 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" +) + +// TestSetupNoSMTPFlow pins the no-SMTP onboarding contract: setup completes on email +// RECORDED + passkey ENROLLED, never on email verification (the bootstrap has no SMTP, +// so the Owner's address is stored unverified). The lockdown must therefore lift on a +// passkey, not on email_verified — otherwise the unverified Owner could never leave the +// wizard to reach the Settings/SMTP page. +func TestSetupNoSMTPFlow(t *testing.T) { + repo := newFakeRepo() + // Fresh Owner: admin, no email, unverified, no passkey — exactly post-CompleteOwnerSetup. + repo.staff["owner"] = &StaffUser{ID: "o1", Username: "owner", Role: "admin"} + + api := newTestAPI(repo, newFakeCluster()) + // A lockdown session principal: authenticated by session, email not yet verified. + api.External = staticExternal{p: &Principal{UserID: "o1", Role: "admin", ViaSession: true}} + h := api.ExternalHandler() + + status := func(t *testing.T) map[string]any { + t.Helper() + w := do(h, "GET", "/api/v1/auth/setup/status", "", nil) + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("status body not JSON: %v", err) + } + return got + } + + // 1. Nothing done → setup required, no email, no passkey. + if s := status(t); s["setup_required"] != true || s["email"] != "" || s["has_passkey"] != false { + t.Fatalf("fresh owner status = %v, want setup_required=true email=\"\" has_passkey=false", s) + } + + // 2. Record the email — NO OTP. The row is written but email_verified stays false. + w := do(h, "POST", "/api/v1/account/email", `{"email":"me@flyemoji.moe"}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("set-email code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if u := repo.staff["owner"]; u.Email != "me@flyemoji.moe" || u.EmailVerified { + t.Fatalf("after record: email=%q verified=%v, want the address recorded and UNVERIFIED", u.Email, u.EmailVerified) + } + + // 3. Email recorded but no passkey → STILL required (email verification is not the gate). + if s := status(t); s["setup_required"] != true || s["email"] != "me@flyemoji.moe" { + t.Fatalf("email-only status = %v, want setup_required=true (passkey still missing)", s) + } + + // 4. The lockdown must still fence a non-SetupAllowed route: no passkey ⇒ 403 setup_required. + w = do(h, "POST", "/api/v1/me/submissions", `{}`, jsonHeader) + if w.Code != http.StatusForbidden || errCode(w.Body.Bytes()) != "setup_required" { + t.Fatalf("pre-passkey locked route: code=%d err=%q, want 403 setup_required (%s)", w.Code, errCode(w.Body.Bytes()), w.Body.String()) + } + + // 5. Enroll a passkey (the Owner's only pre-SMTP credential). + repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: "o1", CredentialID: "cred1"} + + // 6. Passkey present ⇒ setup complete AND the lockdown lifts (the route no longer 403s setup_required). + if s := status(t); s["setup_required"] != false || s["has_passkey"] != true { + t.Fatalf("post-passkey status = %v, want setup_required=false has_passkey=true", s) + } + w = do(h, "POST", "/api/v1/me/submissions", `{}`, jsonHeader) + if w.Code == http.StatusForbidden && errCode(w.Body.Bytes()) == "setup_required" { + t.Fatalf("post-passkey the lockdown did NOT lift: route still 403 setup_required") + } +} + +// TestSetEmailClearsVerified pins the invariant that recording an unproven address +// drops any prior verification: /account/email is app-tier + SetupAllowed, so any +// authenticated session can reach it — an already-verified caller who changes their +// address must NOT keep email_verified=true asserting a proof they never gave. Only +// VerifyEmailOTP (which proves the address) may set that flag. +func TestSetEmailClearsVerified(t *testing.T) { + repo := newFakeRepo() + // A fully onboarded staff account: email already proven. + repo.staff["u"] = &StaffUser{ID: "u1", Username: "u", Role: "admin", Email: "old@x.test", EmailVerified: true} + + api := newTestAPI(repo, newFakeCluster()) + api.External = staticExternal{p: &Principal{UserID: "u1", Role: "admin", ViaSession: true, EmailVerified: true}} + h := api.ExternalHandler() + + w := do(h, "POST", "/api/v1/account/email", `{"email":"new@x.test"}`, jsonHeader) + if w.Code != http.StatusOK { + t.Fatalf("set-email code = %d, want 200 (%s)", w.Code, w.Body.String()) + } + if u := repo.staff["u"]; u.Email != "new@x.test" || u.EmailVerified { + t.Fatalf("after record: email=%q verified=%v, want new address recorded and verification CLEARED", u.Email, u.EmailVerified) + } +} + +// errCode returns the error.code of a JSON error body, or "" if body is not one (a +// non-failing decodeErr for cases where the response may be a success). +func errCode(body []byte) string { + var raw map[string]map[string]string + if json.Unmarshal(body, &raw) != nil { + return "" + } + return raw["error"]["code"] +} diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index 662db11..fb7dccd 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -737,6 +737,29 @@ func (p *PGRepo) VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash s return email, nil } +// SetUserEmail records email on the user row WITHOUT verifying it (setup bootstrap +// has no SMTP — the Owner enters an address a later Settings/SMTP flow will verify). +// It clears email_verified in the same write: only VerifyEmailOTP ever sets that +// flag, and it does so only alongside the proven address, so recording a fresh +// (unproven) address must drop any prior verification rather than leave a stale +// email_verified=true asserting an address the user never proved. For a fresh Owner +// the flag is already false, so this is a no-op there. +func (p *PGRepo) SetUserEmail(ctx context.Context, userID, email string) error { + res, err := p.db.ExecContext(ctx, + `UPDATE users SET email = $2, email_verified = false WHERE id = $1`, userID, email) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + // ---- player game-login: username-collision reclaim (spec §B3) ---- // ReclaimUsername bars the squatter UUID and stashes its data hold in one diff --git a/internal/api/repo.go b/internal/api/repo.go index 50b3fc1..f46443c 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -310,6 +310,13 @@ type Repo interface { // becomes proven, so the write is load-bearing. The pre-session LOGIN door must // NOT use it — see ConsumeLoginEmailOTP. VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (email string, err error) + // SetUserEmail records email on the user row WITHOUT proving control of it, and + // clears email_verified in the same write (proving nothing, it must never leave a + // stale verified flag — see the PGRepo impl). This backs the setup wizard's Step 1: + // the bootstrap has no SMTP, so the Owner cannot receive an emailed code, and the + // address is stored unverified for a later Settings/SMTP flow to verify. An unknown + // userID returns ErrNotFound. + SetUserEmail(ctx context.Context, userID, email string) error // ConsumeLoginEmailOTP redeems the newest live code for (userID, purpose) against // codeHash for the PRE-SESSION email LOGIN door, with the SAME code lifecycle as // VerifyEmailOTP (FOR UPDATE, expiry+lockout before hash compare, mismatch charges diff --git a/panel/src/i18n/resources/en-US/auth.json b/panel/src/i18n/resources/en-US/auth.json index 40cde61..225ab97 100644 --- a/panel/src/i18n/resources/en-US/auth.json +++ b/panel/src/i18n/resources/en-US/auth.json @@ -47,15 +47,12 @@ "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_email_step": "Step 1 · Add your email", + "setup_email_desc": "We'll save this address for your account. You can configure email delivery (SMTP) and verify it later from Settings — it isn't required to finish setup.", + "setup_email_save": "Save & continue", "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_passkey_desc": "Create a passkey with your fingerprint, face, or device PIN. It's how you'll sign in to the console — required to finish setup.", "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 29ee36e..347c433 100644 --- a/panel/src/i18n/resources/zh-CN/auth.json +++ b/panel/src/i18n/resources/zh-CN/auth.json @@ -47,15 +47,12 @@ "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_email_step": "第一步 · 填写邮箱", + "setup_email_desc": "我们会为你的账户保存这个邮箱地址。发信服务(SMTP)和邮箱验证可稍后在设置中配置——完成初始化并不需要它。", + "setup_email_save": "保存并继续", "setup_passkey_step": "第二步 · 注册通行密钥", - "setup_passkey_desc": "使用指纹、面容或设备 PIN 创建一个通行密钥,作为你登录控制台的主要方式;邮箱验证码是备用方式。", + "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 8a38725..7655895 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -351,6 +351,12 @@ export const api = { emailVerify: (code: string) => request<{ verified: boolean; email: string }>("POST", "/account/email/verify", { code }), + // setEmail records the caller's address WITHOUT an OTP round-trip (the setup + // wizard's Step 1). The bootstrap has no SMTP, so email_verified stays false; a + // later Settings/SMTP flow verifies it via emailStart/emailVerify. + setEmail: (email: string) => + request<{ email: string }>("POST", "/account/email", { email }), + passkeyRegisterBegin: () => request("POST", "/account/passkey/register/begin"), diff --git a/panel/src/pages/Setup.tsx b/panel/src/pages/Setup.tsx index a01ca1b..b8fd994 100644 --- a/panel/src/pages/Setup.tsx +++ b/panel/src/pages/Setup.tsx @@ -15,15 +15,17 @@ import { useTier } from "@/lib/tier"; // `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 +// two remaining steps — record an 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. +// The email is only RECORDED, not verified: the bootstrap has no SMTP, so there is no +// code to mail. Passkey is the Owner's only pre-SMTP login credential and is +// mandatory. 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 step endpoints and /me are all SetupAllowed, so the lockdown +// session can complete the wizard; the backend lifts the lockdown once a passkey is +// enrolled, and we hand off to / once nothing remains. export function Setup() { const [params] = useSearchParams(); const navigate = useNavigate(); @@ -76,8 +78,7 @@ export function Setup() { }, []); // 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. + // off to the dashboard. Idempotent — guarded so the completion effect fires once. const finish = useCallback(async () => { if (finishing.current) return; finishing.current = true; @@ -85,8 +86,7 @@ export function Setup() { 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. + // Once nothing remains (email recorded AND a passkey enrolled), hand off. useEffect(() => { if (state && !state.setup_required) void finish(); }, [state, finish]); @@ -135,10 +135,10 @@ export function Setup() { - {!state.email_verified ? ( - + {!state.email ? ( + ) : !state.has_passkey ? ( - void finish()} /> + ) : (
@@ -151,48 +151,31 @@ export function Setup() { ); } -/** 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. */ +/** EmailStep is the §B setup Step 1: record the Owner's email. The bootstrap has no + * SMTP, so there is no code to send — the address is stored UNVERIFIED (a later + * Settings/SMTP flow verifies it). On success it calls onRecorded (a status re-read) + * so the wizard advances to the passkey step. */ function EmailStep({ initialEmail, - onVerified, + onRecorded, }: { initialEmail: string | null; - onVerified: () => Promise; + onRecorded: () => 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) { + async function save(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 + await api.setEmail(addr); + await onRecorded(); // advances (unmounts this step) — no need to clear busy } catch (err) { setError(humanizeError(err)); setBusy(false); @@ -205,96 +188,49 @@ function EmailStep({ {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}

} - - -
- )} + 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. */ + * endpoints — the same ceremony as the Account page. Passkey is the Owner's ONLY + * login credential before SMTP exists (email-OTP login refuses admins; op-login + * needs SMTP + a second admin), so it is mandatory: there is no skip, and the + * backend lockdown lifts only once a passkey is enrolled. */ function PasskeyStep({ onEnrolled, - onSkip, }: { onEnrolled: () => Promise; - onSkip: () => void; }) { const { t } = useTranslation("auth"); const [busy, setBusy] = useState(false); @@ -368,14 +304,6 @@ function PasskeyStep({ )} - ); }