fix(setup): record email unverified so onboarding works without SMTP
At bootstrap there is no SMTP, so the old /setup flow was unreachable: it requested an emailed OTP that could never arrive. Setup now records the Owner's email address unverified (no OTP round-trip) and requires a passkey, deferring SMTP configuration to a later Settings page. Setup completes on email-recorded + passkey-enrolled, and the lockdown lifts on the passkey, not on email_verified: a passkey is the Owner's only pre-SMTP login credential (email-OTP login refuses admin accounts). The record-email endpoint (POST /account/email) now clears email_verified in the same write. Only VerifyEmailOTP, which proves control of the address, may set that flag; recording a fresh unproven address must never leave a stale email_verified=true asserting a proof the user never gave. The change strictly tightens the invariant, so no existing reader breaks. Remove the dead ErrEmailTaken path and its documented 409: no migration puts a unique index on users.email and the codebase does not enforce email uniqueness, so the unique-violation branch was unreachable and the 409 an impossible response. The /setup route (Setup.tsx, setEmail helper, setup i18n copy) is rewritten to match: record-email, mandatory passkey, no skip-for-now. The SMTP settings page and post-setup configure-SMTP nudge are deferred.
This commit is contained in:
12 files changed
+328
-155
No files matched your search
@@ -3317,6 +3317,46 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
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:
|
/api/v1/account/passkey/register/begin:
|
||||||
post:
|
post:
|
||||||
tags: [account]
|
tags: [account]
|
||||||
|
|||||||
+27
-12
@@ -391,6 +391,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
// email is an ordinary authenticated operation, scoped to the principal.
|
// 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/start", SetupAllowed: true, h: a.handleEmailOTPStart},
|
||||||
{Method: "POST", Pattern: "/api/v1/account/email/verify", SetupAllowed: true, h: a.handleEmailOTPVerify},
|
{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
|
// Passkey enrollment (spec §14 WebAuthn / Phase 6 bind), web side: /register/begin
|
||||||
// mints a credential-creation challenge for the caller, /register/finish verifies
|
// mints a credential-creation challenge for the caller, /register/finish verifies
|
||||||
// the authenticator's attestation and binds the passkey, and the credentials
|
// 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
|
// explicitly opts out. The wrapper is nil-principal safe, so it is inert on
|
||||||
// the internal face (service-token callers carry no Principal).
|
// the internal face (service-token callers carry no Principal).
|
||||||
if !rt.SetupAllowed {
|
if !rt.SetupAllowed {
|
||||||
h = a.requireEmailVerified(h)
|
h = a.requireOnboarded(h)
|
||||||
}
|
}
|
||||||
auth.HandleFunc(pattern, h)
|
auth.HandleFunc(pattern, h)
|
||||||
}
|
}
|
||||||
@@ -542,20 +546,31 @@ func (a *API) baseChain(h http.Handler) http.Handler {
|
|||||||
return withRequestID(withRecover(h))
|
return withRequestID(withRecover(h))
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireEmailVerified fences an authenticated route behind the setup-lockdown:
|
// requireOnboarded fences an authenticated route behind the setup-lockdown: a
|
||||||
// a session whose EmailVerified is false (a freshly-onboarded principal that has
|
// freshly-onboarded principal that has not finished setup is restricted to
|
||||||
// not yet proved control of its email) is restricted to SetupAllowed routes only.
|
// SetupAllowed routes only. The lockdown lifts on a durable login credential, NOT
|
||||||
// The wrapper is nil-principal safe, so it is inert on the internal face
|
// on email verification: the bootstrap Owner has no verified email (no SMTP exists
|
||||||
// (service-token callers carry no Principal) and on the external face's admin
|
// at bootstrap) and a passkey is the ONLY credential that logs the Owner in
|
||||||
// Zero-Trust paths (those carry an IsAdmin/IsOwner principal that has already
|
// pre-SMTP (email-OTP login refuses admin accounts; op-login needs SMTP + a second
|
||||||
// passed email verification at account creation).
|
// admin). So passkey enrollment is what completes setup — and it must, or the
|
||||||
func (a *API) requireEmailVerified(h http.HandlerFunc) http.HandlerFunc {
|
// 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := principalFromContext(r.Context())
|
p := principalFromContext(r.Context())
|
||||||
if p != nil && p.ViaSession && !p.EmailVerified {
|
if p != nil && p.ViaSession && !p.EmailVerified {
|
||||||
writeError(w, r, newError(http.StatusForbidden, "setup_required",
|
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
|
||||||
"email verification is required before this action is available"))
|
if len(creds) == 0 {
|
||||||
return
|
writeError(w, r, newError(http.StatusForbidden, "setup_required",
|
||||||
|
"passkey enrollment is required before this action is available"))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
h(w, r)
|
h(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,6 +359,16 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s
|
|||||||
}
|
}
|
||||||
return live.email, nil
|
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
|
// CreatePasskeyChallenge / ConsumePasskeyChallengeByUser mirror PGRepo's contract so
|
||||||
// the hermetic tests exercise the same semantics: a fresh begin supersedes ALL prior
|
// the hermetic tests exercise the same semantics: a fresh begin supersedes ALL prior
|
||||||
|
|||||||
@@ -230,6 +230,42 @@ func (a *API) deliverOTP(ctx context.Context, email, code string) error {
|
|||||||
return a.Mailer.SendOTP(ctx, email, code)
|
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
|
// 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
|
// 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.
|
// not have a verified email yet, so the id keeps the audit row attributable.
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
|
|||||||
"email": u.Email,
|
"email": u.Email,
|
||||||
"email_verified": u.EmailVerified,
|
"email_verified": u.EmailVerified,
|
||||||
"has_passkey": hasPasskey,
|
"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": u.Email,
|
||||||
"email_verified": u.EmailVerified,
|
"email_verified": u.EmailVerified,
|
||||||
"has_passkey": hasPasskey,
|
"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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -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":"[email protected]"}`, 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 != "[email protected]" || 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"] != "[email protected]" {
|
||||||
|
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: "[email protected]", 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":"[email protected]"}`, 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 != "[email protected]" || 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"]
|
||||||
|
}
|
||||||
@@ -737,6 +737,29 @@ func (p *PGRepo) VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash s
|
|||||||
return email, nil
|
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) ----
|
// ---- player game-login: username-collision reclaim (spec §B3) ----
|
||||||
|
|
||||||
// ReclaimUsername bars the squatter UUID and stashes its data hold in one
|
// ReclaimUsername bars the squatter UUID and stashes its data hold in one
|
||||||
|
|||||||
@@ -310,6 +310,13 @@ type Repo interface {
|
|||||||
// becomes proven, so the write is load-bearing. The pre-session LOGIN door must
|
// becomes proven, so the write is load-bearing. The pre-session LOGIN door must
|
||||||
// NOT use it — see ConsumeLoginEmailOTP.
|
// NOT use it — see ConsumeLoginEmailOTP.
|
||||||
VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (email string, err error)
|
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
|
// ConsumeLoginEmailOTP redeems the newest live code for (userID, purpose) against
|
||||||
// codeHash for the PRE-SESSION email LOGIN door, with the SAME code lifecycle as
|
// codeHash for the PRE-SESSION email LOGIN door, with the SAME code lifecycle as
|
||||||
// VerifyEmailOTP (FOR UPDATE, expiry+lockout before hash compare, mismatch charges
|
// VerifyEmailOTP (FOR UPDATE, expiry+lockout before hash compare, mismatch charges
|
||||||
|
|||||||
@@ -47,15 +47,12 @@
|
|||||||
"setup_invalid_hint_prefix": "Re-run ",
|
"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_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_goto_login": "Go to sign in",
|
||||||
"setup_email_step": "Step 1 · Verify email",
|
"setup_email_step": "Step 1 · Add your 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_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_otp_sent": "Code sent to {{email}}",
|
"setup_email_save": "Save & continue",
|
||||||
"setup_verify_continue": "Verify & continue",
|
|
||||||
"setup_change_email": "Change email / resend",
|
|
||||||
"setup_passkey_step": "Step 2 · Register a passkey",
|
"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_create_passkey": "Create passkey",
|
||||||
"setup_registering": "Registering…",
|
"setup_registering": "Registering…",
|
||||||
"setup_skip": "Skip for now — go to the console",
|
|
||||||
"setup_default_passkey_name": "Default passkey"
|
"setup_default_passkey_name": "Default passkey"
|
||||||
}
|
}
|
||||||
@@ -47,15 +47,12 @@
|
|||||||
"setup_invalid_hint_prefix": "请在服务器上重新运行 ",
|
"setup_invalid_hint_prefix": "请在服务器上重新运行 ",
|
||||||
"setup_invalid_hint_suffix": " 获取新的设置链接,或直接前往登录页。",
|
"setup_invalid_hint_suffix": " 获取新的设置链接,或直接前往登录页。",
|
||||||
"setup_goto_login": "前往登录",
|
"setup_goto_login": "前往登录",
|
||||||
"setup_email_step": "第一步 · 验证邮箱",
|
"setup_email_step": "第一步 · 填写邮箱",
|
||||||
"setup_email_desc": "我们会向你的邮箱发送验证码,用于找回账户,也是通行密钥不可用时的备用登录方式。",
|
"setup_email_desc": "我们会为你的账户保存这个邮箱地址。发信服务(SMTP)和邮箱验证可稍后在设置中配置——完成初始化并不需要它。",
|
||||||
"setup_otp_sent": "验证码已发送至 {{email}}",
|
"setup_email_save": "保存并继续",
|
||||||
"setup_verify_continue": "验证并继续",
|
|
||||||
"setup_change_email": "换个邮箱 / 重新发送",
|
|
||||||
"setup_passkey_step": "第二步 · 注册通行密钥",
|
"setup_passkey_step": "第二步 · 注册通行密钥",
|
||||||
"setup_passkey_desc": "使用指纹、面容或设备 PIN 创建一个通行密钥,作为你登录控制台的主要方式;邮箱验证码是备用方式。",
|
"setup_passkey_desc": "使用指纹、面容或设备 PIN 创建一个通行密钥,这将是你登录控制台的方式——完成初始化必须注册。",
|
||||||
"setup_create_passkey": "创建通行密钥",
|
"setup_create_passkey": "创建通行密钥",
|
||||||
"setup_registering": "注册中…",
|
"setup_registering": "注册中…",
|
||||||
"setup_skip": "暂时跳过,直接进入控制台",
|
|
||||||
"setup_default_passkey_name": "默认通行密钥"
|
"setup_default_passkey_name": "默认通行密钥"
|
||||||
}
|
}
|
||||||
@@ -351,6 +351,12 @@ export const api = {
|
|||||||
emailVerify: (code: string) =>
|
emailVerify: (code: string) =>
|
||||||
request<{ verified: boolean; email: string }>("POST", "/account/email/verify", { code }),
|
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: () =>
|
passkeyRegisterBegin: () =>
|
||||||
request<any>("POST", "/account/passkey/register/begin"),
|
request<any>("POST", "/account/passkey/register/begin"),
|
||||||
|
|
||||||
|
|||||||
+55
-127
@@ -15,15 +15,17 @@ import { useTier } from "@/lib/tier";
|
|||||||
// `felis setup` MC-bind flow prints https://op.console.<root>/setup?token=<raw> —
|
// `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
|
// 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
|
// 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
|
// dashboard. It sits OUTSIDE RequireAuth (like /login): the visitor arrives without
|
||||||
// a session, and the redeem is what creates one.
|
// a session, and the redeem is what creates one.
|
||||||
//
|
//
|
||||||
// Reload-safe: the token is single-use, so a refresh mid-wizard re-reads progress
|
// The email is only RECORDED, not verified: the bootstrap has no SMTP, so there is no
|
||||||
// from /auth/setup/status (the surviving session) rather than dead-ending on a
|
// code to mail. Passkey is the Owner's only pre-SMTP login credential and is
|
||||||
// spent token. The two step endpoints and /me are all SetupAllowed, so the lockdown
|
// mandatory. Reload-safe: the token is single-use, so a refresh mid-wizard re-reads
|
||||||
// session can complete the wizard; the backend lifts the lockdown once email is
|
// progress from /auth/setup/status (the surviving session) rather than dead-ending on
|
||||||
// verified, and we hand off to / once nothing remains.
|
// 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() {
|
export function Setup() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -76,8 +78,7 @@ export function Setup() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// finish re-reads /me (so RequireAuth sees the authenticated session) and hands
|
// 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
|
// off to the dashboard. Idempotent — guarded so the completion effect fires once.
|
||||||
// both reach here.
|
|
||||||
const finish = useCallback(async () => {
|
const finish = useCallback(async () => {
|
||||||
if (finishing.current) return;
|
if (finishing.current) return;
|
||||||
finishing.current = true;
|
finishing.current = true;
|
||||||
@@ -85,8 +86,7 @@ export function Setup() {
|
|||||||
navigate("/", { replace: true });
|
navigate("/", { replace: true });
|
||||||
}, [refresh, navigate]);
|
}, [refresh, navigate]);
|
||||||
|
|
||||||
// Once nothing remains (email verified AND a passkey exists, or the owner skipped
|
// Once nothing remains (email recorded AND a passkey enrolled), hand off.
|
||||||
// to a backend-valid state), hand off.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state && !state.setup_required) void finish();
|
if (state && !state.setup_required) void finish();
|
||||||
}, [state, finish]);
|
}, [state, finish]);
|
||||||
@@ -135,10 +135,10 @@ export function Setup() {
|
|||||||
<AuthLayout title={t("setup_title")} subtitle={t("setup_welcome", { name: state.username })}>
|
<AuthLayout title={t("setup_title")} subtitle={t("setup_welcome", { name: state.username })}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
{!state.email_verified ? (
|
{!state.email ? (
|
||||||
<EmailStep initialEmail={state.email} onVerified={reload} />
|
<EmailStep initialEmail={state.email} onRecorded={reload} />
|
||||||
) : !state.has_passkey ? (
|
) : !state.has_passkey ? (
|
||||||
<PasskeyStep onEnrolled={reload} onSkip={() => void finish()} />
|
<PasskeyStep onEnrolled={reload} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
@@ -151,48 +151,31 @@ export function Setup() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** EmailStep is the §B email-OTP step: send a code, then verify it. On success it
|
/** EmailStep is the §B setup Step 1: record the Owner's email. The bootstrap has no
|
||||||
* calls onVerified (a status re-read) so the wizard advances to the passkey step.
|
* SMTP, so there is no code to send — the address is stored UNVERIFIED (a later
|
||||||
* Mirrors the Account page's email card against the same SetupAllowed endpoints. */
|
* Settings/SMTP flow verifies it). On success it calls onRecorded (a status re-read)
|
||||||
|
* so the wizard advances to the passkey step. */
|
||||||
function EmailStep({
|
function EmailStep({
|
||||||
initialEmail,
|
initialEmail,
|
||||||
onVerified,
|
onRecorded,
|
||||||
}: {
|
}: {
|
||||||
initialEmail: string | null;
|
initialEmail: string | null;
|
||||||
onVerified: () => Promise<void>;
|
onRecorded: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("auth");
|
const { t } = useTranslation("auth");
|
||||||
const [email, setEmail] = useState(initialEmail ?? "");
|
const [email, setEmail] = useState(initialEmail ?? "");
|
||||||
const [otp, setOtp] = useState("");
|
|
||||||
const [sent, setSent] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function send(e: FormEvent) {
|
async function save(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const addr = email.trim();
|
const addr = email.trim();
|
||||||
if (!addr || busy) return;
|
if (!addr || busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await api.emailStart(addr);
|
await api.setEmail(addr);
|
||||||
setSent(true);
|
await onRecorded(); // advances (unmounts this step) — no need to clear busy
|
||||||
} 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) {
|
} catch (err) {
|
||||||
setError(humanizeError(err));
|
setError(humanizeError(err));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -205,96 +188,49 @@ function EmailStep({
|
|||||||
<Mail className="h-4 w-4 text-primary" /> {t("setup_email_step")}
|
<Mail className="h-4 w-4 text-primary" /> {t("setup_email_step")}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t("setup_email_desc")}</p>
|
<p className="text-sm text-muted-foreground">{t("setup_email_desc")}</p>
|
||||||
{!sent ? (
|
<form onSubmit={save} className="space-y-3">
|
||||||
<form onSubmit={send} className="space-y-3">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<Label htmlFor="setup-email">{t("email_address")}</Label>
|
||||||
<Label htmlFor="setup-email">{t("email_address")}</Label>
|
<Input
|
||||||
<Input
|
id="setup-email"
|
||||||
id="setup-email"
|
type="email"
|
||||||
type="email"
|
value={email}
|
||||||
value={email}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
autoComplete="email"
|
||||||
autoComplete="email"
|
autoCapitalize="none"
|
||||||
autoCapitalize="none"
|
autoCorrect="off"
|
||||||
autoCorrect="off"
|
spellCheck={false}
|
||||||
spellCheck={false}
|
placeholder="[email protected]"
|
||||||
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}
|
disabled={busy}
|
||||||
className="w-full text-center text-xs text-muted-foreground hover:text-primary disabled:opacity-50"
|
autoFocus
|
||||||
>
|
aria-invalid={error ? true : undefined}
|
||||||
{t("setup_change_email")}
|
/>
|
||||||
</button>
|
</div>
|
||||||
</form>
|
{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("saving")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("setup_email_save")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PasskeyStep enrolls the Owner's first passkey against the SetupAllowed register
|
/** PasskeyStep enrolls the Owner's first passkey against the SetupAllowed register
|
||||||
* endpoints — the same ceremony as the Account page. Email is already verified at
|
* endpoints — the same ceremony as the Account page. Passkey is the Owner's ONLY
|
||||||
* this point (backend lockdown lifted), so "skip" is a safe escape if the
|
* login credential before SMTP exists (email-OTP login refuses admins; op-login
|
||||||
* authenticator misbehaves: the owner lands in the console and can enroll later. */
|
* 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({
|
function PasskeyStep({
|
||||||
onEnrolled,
|
onEnrolled,
|
||||||
onSkip,
|
|
||||||
}: {
|
}: {
|
||||||
onEnrolled: () => Promise<void>;
|
onEnrolled: () => Promise<void>;
|
||||||
onSkip: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("auth");
|
const { t } = useTranslation("auth");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -368,14 +304,6 @@ function PasskeyStep({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user