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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user