1
0
forked from wrenn/wrenn

Add email activation flow and replace is_active with status column

Email signup now creates inactive users who must activate via a 30-minute
email token before signing in. Team creation is deferred to first login
after activation, while OAuth users continue to get teams immediately.

- Replace boolean is_active with status column (inactive/active/disabled/deleted)
- Add POST /v1/auth/activate endpoint with Redis-backed token consumption
- Signup returns message instead of JWT, sends activation email
- Login differentiates error messages by user status
- Add confirm password field to signup form
- Add /activate frontend page that auto-logs in on success
- Handle inactive user cleanup on re-signup (30-min cooldown) and OAuth collision
This commit is contained in:
2026-04-16 04:05:41 +06:00
parent e8a2217247
commit a3f75300a9
18 changed files with 726 additions and 265 deletions

View File

@ -217,8 +217,8 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
redirectWithError(w, r, redirectBase, "db_error")
return
}
if !user.IsActive {
slog.Warn("oauth login: account deactivated", "email", user.Email)
if user.Status != "active" {
slog.Warn("oauth login: account not active", "email", user.Email, "status", user.Status)
redirectWithError(w, r, redirectBase, "account_deactivated")
return
}
@ -244,13 +244,21 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
}
// New OAuth identity — check for email collision.
_, err = h.db.GetUserByEmail(ctx, email)
existingUser, err := h.db.GetUserByEmail(ctx, email)
if err == nil {
// Email already taken by another account.
redirectWithError(w, r, redirectBase, "email_taken")
return
}
if !errors.Is(err, pgx.ErrNoRows) {
if existingUser.Status == "inactive" {
// Unactivated email signup — delete and let OAuth take over.
if delErr := h.db.HardDeleteUser(ctx, existingUser.ID); delErr != nil {
slog.Error("oauth: failed to delete inactive user", "error", delErr)
redirectWithError(w, r, redirectBase, "db_error")
return
}
} else {
// Email already taken by an active/disabled/deleted account.
redirectWithError(w, r, redirectBase, "email_taken")
return
}
} else if !errors.Is(err, pgx.ErrNoRows) {
slog.Error("oauth: email check failed", "error", err)
redirectWithError(w, r, redirectBase, "db_error")
return
@ -373,8 +381,8 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov
redirectWithError(w, r, redirectBase, "db_error")
return
}
if !user.IsActive {
slog.Warn("oauth: retry login: account deactivated", "email", user.Email)
if user.Status != "active" {
slog.Warn("oauth: retry login: account not active", "email", user.Email, "status", user.Status)
redirectWithError(w, r, redirectBase, "account_deactivated")
return
}