From a3f75300a9fa9692a8b3704eebb5e5975b475a67 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Thu, 16 Apr 2026 04:05:41 +0600 Subject: [PATCH] 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 --- ...15215033_replace_is_active_with_status.sql | 15 + db/queries/users.sql | 19 +- frontend/src/lib/api/admin-users.ts | 2 +- frontend/src/lib/api/auth.ts | 15 +- frontend/src/routes/activate/+page.svelte | 75 ++++ frontend/src/routes/admin/users/+page.svelte | 12 +- frontend/src/routes/login/+page.svelte | 300 +++++++++------- internal/api/handlers_auth.go | 321 ++++++++++++++---- internal/api/handlers_me.go | 2 +- internal/api/handlers_oauth.go | 28 +- internal/api/handlers_users.go | 15 +- internal/api/middleware_auth.go | 2 +- internal/api/middleware_jwt.go | 2 +- internal/api/openapi.yaml | 51 ++- internal/api/server.go | 3 +- pkg/db/models.go | 2 +- pkg/db/users.sql.go | 111 ++++-- pkg/service/user.go | 16 +- 18 files changed, 726 insertions(+), 265 deletions(-) create mode 100644 db/migrations/20260415215033_replace_is_active_with_status.sql create mode 100644 frontend/src/routes/activate/+page.svelte diff --git a/db/migrations/20260415215033_replace_is_active_with_status.sql b/db/migrations/20260415215033_replace_is_active_with_status.sql new file mode 100644 index 0000000..2ea091a --- /dev/null +++ b/db/migrations/20260415215033_replace_is_active_with_status.sql @@ -0,0 +1,15 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; + +-- Backfill from existing columns. +UPDATE users SET status = 'deleted' WHERE deleted_at IS NOT NULL; +UPDATE users SET status = 'disabled' WHERE is_active = false AND deleted_at IS NULL; + +ALTER TABLE users DROP COLUMN is_active; + +-- +goose Down +ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE; + +UPDATE users SET is_active = false WHERE status IN ('inactive', 'disabled', 'deleted'); + +ALTER TABLE users DROP COLUMN status; diff --git a/db/queries/users.sql b/db/queries/users.sql index 30efe78..1c902a3 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -14,6 +14,11 @@ INSERT INTO users (id, email, name) VALUES ($1, $2, $3) RETURNING *; +-- name: InsertUserInactive :one +INSERT INTO users (id, email, password_hash, name, status) +VALUES ($1, $2, $3, $4, 'inactive') +RETURNING *; + -- name: SetUserAdmin :exec UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1; @@ -38,6 +43,9 @@ SELECT EXISTS( -- name: CountUsers :one SELECT COUNT(*) FROM users; +-- name: CountActiveUsers :one +SELECT COUNT(*) FROM users WHERE status = 'active'; + -- name: SearchUsersByEmailPrefix :many SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10; @@ -50,7 +58,7 @@ SELECT u.email, u.name, u.is_admin, - u.is_active, + u.status, u.created_at, (SELECT COUNT(*) FROM users_teams ut WHERE ut.user_id = u.id)::int AS teams_joined, (SELECT COUNT(*) FROM users_teams ut WHERE ut.user_id = u.id AND ut.role = 'owner')::int AS teams_owned @@ -64,14 +72,14 @@ SELECT COUNT(*)::int AS total FROM users WHERE deleted_at IS NULL; --- name: SetUserActive :exec -UPDATE users SET is_active = $2, updated_at = NOW() WHERE id = $1; +-- name: SetUserStatus :exec +UPDATE users SET status = $2, updated_at = NOW() WHERE id = $1; -- name: UpdateUserPassword :exec UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1; -- name: SoftDeleteUser :exec -UPDATE users SET deleted_at = NOW(), is_active = false, updated_at = NOW() WHERE id = $1; +UPDATE users SET deleted_at = NOW(), status = 'deleted', updated_at = NOW() WHERE id = $1; -- name: CountUserOwnedTeamsWithOtherMembers :one SELECT COUNT(DISTINCT ut.team_id)::int @@ -85,3 +93,6 @@ WHERE ut.user_id = $1 -- name: HardDeleteExpiredUsers :exec DELETE FROM users WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '15 days'; + +-- name: HardDeleteUser :exec +DELETE FROM users WHERE id = $1; diff --git a/frontend/src/lib/api/admin-users.ts b/frontend/src/lib/api/admin-users.ts index a8cfa19..c5dd339 100644 --- a/frontend/src/lib/api/admin-users.ts +++ b/frontend/src/lib/api/admin-users.ts @@ -5,7 +5,7 @@ export type AdminUser = { email: string; name: string; is_admin: boolean; - is_active: boolean; + status: string; created_at: string; teams_joined: number; teams_owned: number; diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index 845b8a3..1a3ede9 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -6,17 +6,26 @@ export type AuthResponse = { name: string; }; +export type SignupResponse = { + message: string; +}; + export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: string }; +export type SignupResult = { ok: true; data: SignupResponse } | { ok: false; error: string }; export async function apiLogin(email: string, password: string): Promise { return authFetch('/api/v1/auth/login', { email, password }); } -export async function apiSignup(email: string, password: string, name: string): Promise { +export async function apiSignup(email: string, password: string, name: string): Promise { return authFetch('/api/v1/auth/signup', { email, password, name }); } -async function authFetch(url: string, body: Record): Promise { +export async function apiActivate(token: string): Promise { + return authFetch('/api/v1/auth/activate', { token }); +} + +async function authFetch(url: string, body: Record): Promise<{ ok: true; data: T } | { ok: false; error: string }> { try { const res = await fetch(url, { method: 'POST', @@ -31,7 +40,7 @@ async function authFetch(url: string, body: Record): Promise + import { onMount } from 'svelte'; + import { page } from '$app/stores'; + import { goto } from '$app/navigation'; + import { auth } from '$lib/auth.svelte'; + import { teams } from '$lib/teams.svelte'; + import { apiActivate } from '$lib/api/auth'; + + let loading = $state(true); + let error = $state(''); + let done = $state(false); + + onMount(async () => { + const token = $page.url.searchParams.get('token'); + if (!token) { + error = 'No activation token provided.'; + loading = false; + return; + } + + const result = await apiActivate(token); + loading = false; + + if (!result.ok) { + error = result.error; + return; + } + + done = true; + teams.reset(); + auth.login(result.data); + goto('/dashboard'); + }); + + + + Wrenn — Activate account + + +
+
+ +
+ Wrenn + Wrenn +
+ + {#if loading} +
+
+ +

Activating your account...

+
+
+ {:else if error} +
+

Activation failed

+

{error}

+ + Back to sign in + +
+ {:else if done} +
+
+ +

Redirecting to dashboard...

+
+
+ {/if} +
+
diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index 3e8c46f..e933dc3 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -52,10 +52,10 @@ async function handleToggleActive(user: AdminUser) { togglingId = user.id; - const newActive = !user.is_active; + const newActive = user.status !== 'active'; const result = await setUserActive(user.id, newActive); if (result.ok) { - user.is_active = newActive; + user.status = newActive ? 'active' : 'disabled'; toast.success(`${user.email} ${newActive ? 'activated' : 'deactivated'}`); } else { toast.error(result.error); @@ -195,11 +195,11 @@ {:else} {#each users as user, i (user.id)}
- {#if user.is_active} + {#if user.status === 'active'}
{/if} @@ -247,14 +247,14 @@ onclick={() => handleToggleActive(user)} disabled={togglingId === user.id} class="rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-medium transition-all duration-150 disabled:opacity-50 - {user.is_active + {user.status === 'active' ? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/8 text-[var(--color-accent-bright)] hover:bg-[var(--color-accent)]/15 hover:border-[var(--color-accent)]/50' : 'border-[var(--color-red)]/30 bg-[var(--color-red)]/8 text-[var(--color-red)] hover:bg-[var(--color-red)]/15 hover:border-[var(--color-red)]/50'}" > {#if togglingId === user.id} {:else} - {user.is_active ? 'Active' : 'Inactive'} + {user.status === 'active' ? 'Active' : user.status.charAt(0).toUpperCase() + user.status.slice(1)} {/if}
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 3e07c1c..e2bf294 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -17,10 +17,12 @@ let mode: 'signin' | 'signup' = $state('signin'); let email = $state(''); let password = $state(''); + let confirmPassword = $state(''); let name = $state(''); let showPassword = $state(false); let error = $state(''); let loading = $state(false); + let signupDone = $state(false); const oauthErrorMessages: Record = { account_deactivated: 'Your account has been deactivated — contact your administrator to regain access', @@ -90,6 +92,8 @@ mode = mode === 'signin' ? 'signup' : 'signin'; error = ''; name = ''; + confirmPassword = ''; + signupDone = false; } async function handleSubmit(e: Event) { @@ -97,11 +101,32 @@ error = ''; loading = true; - const result = - mode === 'signin' - ? await apiLogin(email, password) - : await apiSignup(email, password, name); + if (mode === 'signup') { + if (password !== confirmPassword) { + error = 'Passwords do not match.'; + loading = false; + return; + } + if (password.length < 8) { + error = 'Password must be at least 8 characters.'; + loading = false; + return; + } + const result = await apiSignup(email, password, name); + loading = false; + + if (!result.ok) { + error = result.error; + return; + } + + signupDone = true; + return; + } + + // Sign in + const result = await apiLogin(email, password); loading = false; if (!result.ok) { @@ -192,141 +217,178 @@
- -
-

+
+

Check your email

+

+ We've sent an activation link to {email}. Click the link to activate your account. +

+

+ The link expires in 30 minutes. If you don't see it, check your spam folder. +

+ +
+ {:else} + +
+

+ {title} +

+

+ {subtitle} +

+
+ + + - {title} -

-

- {subtitle} -

-
+ + Continue with GitHub + - - - - Continue with GitHub - + +
+
+ or +
+
- -
-
- or -
-
- - -
- {#if mode === 'signup'} + + + {#if mode === 'signup'} +
+
+ +
+ +
+ {/if}
- +
- {/if} -
-
- -
- -
-
-
- +
+
+ +
+ +
- + + {#if mode === 'signup'} +
+
+ +
+ +
+ {/if} + + {#if mode === 'signin'} + + {/if} + + {#if error} +

{error}

+ {/if} + -
+ - {#if mode === 'signin'} - - {/if} - - {#if error} -

{error}

- {/if} - - - - - -

- {switchText} - -

+ +

+ {switchText} + +

+ {/if}
diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index d2d8d53..1768606 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -2,15 +2,21 @@ package api import ( "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" "errors" + "fmt" "log/slog" "net/http" "strings" + "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" "git.omukk.dev/wrenn/wrenn/internal/email" "git.omukk.dev/wrenn/wrenn/pkg/auth" @@ -18,6 +24,12 @@ import ( "git.omukk.dev/wrenn/wrenn/pkg/id" ) +const ( + activationKeyPrefix = "wrenn:activation:" + activationTTL = 30 * time.Minute + signupCooldown = 30 * time.Minute +) + // loginTeam returns the team and role to stamp into a login JWT. // It prefers the user's default team; if none is flagged as default it falls // back to the earliest-joined team. Returns pgx.ErrNoRows when the user has @@ -53,19 +65,89 @@ func loginTeam(ctx context.Context, q *db.Queries, userID pgtype.UUID) (db.Team, }, first.Role, nil } +// ensureDefaultTeam creates a default team for a user if they have none. +// This happens on first login after activation or for edge cases where a user +// has no teams. Returns the team, role, and whether the user was set as admin. +func ensureDefaultTeam(ctx context.Context, qtx *db.Queries, pool *pgxpool.Pool, userID pgtype.UUID, userName string) (db.Team, string, bool, error) { + // Try existing teams first. + team, role, err := loginTeam(ctx, qtx, userID) + if err == nil { + return team, role, false, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return db.Team{}, "", false, err + } + + // No teams — create default team in a transaction. + tx, err := pool.Begin(ctx) + if err != nil { + return db.Team{}, "", false, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) //nolint:errcheck + + txq := qtx.WithTx(tx) + + // First active user to have a team becomes admin. + activeCount, err := txq.CountActiveUsers(ctx) + if err != nil { + return db.Team{}, "", false, fmt.Errorf("count active users: %w", err) + } + isFirstUser := activeCount == 1 // only this user is active + + teamID := id.NewTeamID() + teamRow, err := txq.InsertTeam(ctx, db.InsertTeamParams{ + ID: teamID, + Name: userName + "'s Team", + Slug: id.NewTeamSlug(), + }) + if err != nil { + return db.Team{}, "", false, fmt.Errorf("insert team: %w", err) + } + + if err := txq.InsertTeamMember(ctx, db.InsertTeamMemberParams{ + UserID: userID, + TeamID: teamID, + IsDefault: true, + Role: "owner", + }); err != nil { + return db.Team{}, "", false, fmt.Errorf("insert team member: %w", err) + } + + if isFirstUser { + if err := txq.SetUserAdmin(ctx, db.SetUserAdminParams{ID: userID, IsAdmin: true}); err != nil { + return db.Team{}, "", false, fmt.Errorf("set admin: %w", err) + } + } + + if err := tx.Commit(ctx); err != nil { + return db.Team{}, "", false, fmt.Errorf("commit: %w", err) + } + + return db.Team{ + ID: teamRow.ID, + Name: teamRow.Name, + Slug: teamRow.Slug, + IsByoc: teamRow.IsByoc, + CreatedAt: teamRow.CreatedAt, + DeletedAt: teamRow.DeletedAt, + }, "owner", isFirstUser, nil +} + type switchTeamRequest struct { TeamID string `json:"team_id"` } type authHandler struct { - db *db.Queries - pool *pgxpool.Pool - jwtSecret []byte - mailer email.Mailer + db *db.Queries + pool *pgxpool.Pool + jwtSecret []byte + mailer email.Mailer + rdb *redis.Client + redirectURL string } -func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte, mailer email.Mailer) *authHandler { - return &authHandler{db: db, pool: pool, jwtSecret: jwtSecret, mailer: mailer} +func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte, mailer email.Mailer, rdb *redis.Client, redirectURL string) *authHandler { + return &authHandler{db: db, pool: pool, jwtSecret: jwtSecret, mailer: mailer, rdb: rdb, redirectURL: strings.TrimRight(redirectURL, "/")} } type signupRequest struct { @@ -79,6 +161,10 @@ type loginRequest struct { Password string `json:"password"` } +type activateRequest struct { + Token string `json:"token"` +} + type authResponse struct { Token string `json:"token"` UserID string `json:"user_id"` @@ -87,6 +173,10 @@ type authResponse struct { Name string `json:"name"` } +type signupResponse struct { + Message string `json:"message"` +} + // Signup handles POST /v1/auth/signup. func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) { var req signupRequest @@ -112,32 +202,41 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + // Check for existing user with this email. + existing, err := h.db.GetUserByEmail(ctx, req.Email) + if err == nil { + // User exists — decide what to do based on status. + switch existing.Status { + case "inactive": + // Unactivated user — allow re-signup after cooldown. + if time.Since(existing.CreatedAt.Time) < signupCooldown { + writeError(w, http.StatusConflict, "signup_cooldown", + "an activation email was recently sent to this address — please check your inbox or try again later") + return + } + // Cooldown passed — delete the old row and proceed with fresh signup. + if err := h.db.HardDeleteUser(ctx, existing.ID); err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to clean up previous signup") + return + } + default: + // active, disabled, deleted — email is taken. + writeError(w, http.StatusConflict, "email_taken", "an account with this email already exists") + return + } + } else if !errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusInternalServerError, "db_error", "failed to look up user") + return + } + passwordHash, err := auth.HashPassword(req.Password) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password") return } - // Use a transaction to atomically create user + team + membership. - tx, err := h.pool.Begin(ctx) - if err != nil { - writeError(w, http.StatusInternalServerError, "db_error", "failed to begin transaction") - return - } - defer tx.Rollback(ctx) //nolint:errcheck - - qtx := h.db.WithTx(tx) - - // The first user to sign up becomes a platform admin. - userCount, err := qtx.CountUsers(ctx) - if err != nil { - writeError(w, http.StatusInternalServerError, "db_error", "failed to check user count") - return - } - isFirstUser := userCount == 0 - userID := id.NewUserID() - _, err = qtx.InsertUser(ctx, db.InsertUserParams{ + _, err = h.db.InsertUserInactive(ctx, db.InsertUserInactiveParams{ ID: userID, Email: req.Email, PasswordHash: pgtype.Text{String: passwordHash, Valid: true}, @@ -153,61 +252,111 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) { return } - if isFirstUser { - if err := qtx.SetUserAdmin(ctx, db.SetUserAdminParams{ID: userID, IsAdmin: true}); err != nil { - writeError(w, http.StatusInternalServerError, "db_error", "failed to set admin status") - return + // Generate activation token and store in Redis. + rawToken := generateActivationToken() + tokenHash := hashActivationToken(rawToken) + redisKey := activationKeyPrefix + tokenHash + + if err := h.rdb.Set(ctx, redisKey, id.FormatUserID(userID), activationTTL).Err(); err != nil { + slog.Error("signup: failed to store activation token in redis", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "failed to create activation token") + return + } + + activateURL := h.redirectURL + "/activate?token=" + rawToken + go func() { + sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := h.mailer.Send(sendCtx, req.Email, "Activate your Wrenn account", email.EmailData{ + RecipientName: req.Name, + Message: "Welcome to Wrenn! Click the button below to activate your account. This link expires in 30 minutes.", + Button: &email.Button{Text: "Activate Account", URL: activateURL}, + Closing: "If you didn't create this account, you can safely ignore this email.", + }); err != nil { + slog.Warn("signup: failed to send activation email", "email", req.Email, "error", err) } + }() + + writeJSON(w, http.StatusCreated, signupResponse{ + Message: "Account created. Please check your email to activate your account.", + }) +} + +// Activate handles POST /v1/auth/activate. +func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) { + var req activateRequest + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return } - // Create default team. - teamID := id.NewTeamID() - if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{ - ID: teamID, - Name: req.Name + "'s Team", - Slug: id.NewTeamSlug(), + if req.Token == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "token is required") + return + } + + ctx := r.Context() + tokenHash := hashActivationToken(req.Token) + redisKey := activationKeyPrefix + tokenHash + + userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result() + if errors.Is(err, redis.Nil) { + writeError(w, http.StatusBadRequest, "invalid_token", "activation link is invalid or has expired") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "failed to verify token") + return + } + + userID, err := id.ParseUserID(userIDStr) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "invalid stored user ID") + return + } + + user, err := h.db.GetUserByID(ctx, userID) + if err != nil { + writeError(w, http.StatusInternalServerError, "db_error", "failed to get user") + return + } + + if user.Status != "inactive" { + writeError(w, http.StatusBadRequest, "already_activated", "this account has already been activated") + return + } + + // Activate the user. + if err := h.db.SetUserStatus(ctx, db.SetUserStatusParams{ + ID: userID, + Status: "active", }); err != nil { - writeError(w, http.StatusInternalServerError, "db_error", "failed to create team") + slog.Error("activate: failed to set user status", "user_id", id.FormatUserID(userID), "error", err) + writeError(w, http.StatusInternalServerError, "db_error", "failed to activate user") return } - if err := qtx.InsertTeamMember(ctx, db.InsertTeamMemberParams{ - UserID: userID, - TeamID: teamID, - IsDefault: true, - Role: "owner", - }); err != nil { - writeError(w, http.StatusInternalServerError, "db_error", "failed to add user to team") + // Create default team and log them in. + team, role, isFirstUser, err := ensureDefaultTeam(ctx, h.db, h.pool, userID, user.Name) + if err != nil { + slog.Error("activate: failed to create default team", "error", err) + writeError(w, http.StatusInternalServerError, "db_error", "failed to set up account") return } - if err := tx.Commit(ctx); err != nil { - writeError(w, http.StatusInternalServerError, "db_error", "failed to commit signup") - return - } - - token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, req.Name, "owner", isFirstUser) + isAdmin := user.IsAdmin || isFirstUser + token, err := auth.SignJWT(h.jwtSecret, userID, team.ID, user.Email, user.Name, role, isAdmin) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") return } - go func() { - if err := h.mailer.Send(context.Background(), req.Email, "Welcome to Wrenn", email.EmailData{ - RecipientName: req.Name, - Message: "Welcome to Wrenn! Your account has been created and you're ready to start building with secure, isolated sandboxes.", - Closing: "If you have any questions, feel free to reach out. We're glad to have you.", - }); err != nil { - slog.Warn("failed to send welcome email", "email", req.Email, "error", err) - } - }() - - writeJSON(w, http.StatusCreated, authResponse{ + writeJSON(w, http.StatusOK, authResponse{ Token: token, UserID: id.FormatUserID(userID), - TeamID: id.FormatTeamID(teamID), - Email: req.Email, - Name: req.Name, + TeamID: id.FormatTeamID(team.ID), + Email: user.Email, + Name: user.Name, }) } @@ -249,23 +398,36 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) { return } - if !user.IsActive { - slog.Warn("login failed: account deactivated", "email", req.Email, "ip", r.RemoteAddr) - writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access") + switch user.Status { + case "active": + // OK — proceed. + case "inactive": + slog.Warn("login failed: account not activated", "email", req.Email, "ip", r.RemoteAddr) + writeError(w, http.StatusForbidden, "account_not_activated", "please check your email and activate your account before signing in") + return + case "disabled": + slog.Warn("login failed: account disabled", "email", req.Email, "ip", r.RemoteAddr) + writeError(w, http.StatusForbidden, "account_disabled", "your account has been deactivated — contact your administrator to regain access") + return + case "deleted": + slog.Warn("login failed: account deleted", "email", req.Email, "ip", r.RemoteAddr) + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") + return + default: + writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") return } - team, role, err := loginTeam(ctx, h.db, user.ID) + // Ensure user has a default team (creates one on first login after activation). + team, role, isFirstUser, err := ensureDefaultTeam(ctx, h.db, h.pool, user.ID, user.Name) if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - writeError(w, http.StatusForbidden, "no_team", "user is not a member of any team") - return - } + slog.Error("login: failed to ensure default team", "error", err) writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team") return } - token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role, user.IsAdmin) + isAdmin := user.IsAdmin || isFirstUser + token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role, isAdmin) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") return @@ -355,3 +517,18 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) { Name: user.Name, }) } + +// --- helpers --- + +func generateActivationToken() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + return hex.EncodeToString(b) +} + +func hashActivationToken(raw string) string { + h := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(h[:]) +} diff --git a/internal/api/handlers_me.go b/internal/api/handlers_me.go index 7b8b83c..0f0f928 100644 --- a/internal/api/handlers_me.go +++ b/internal/api/handlers_me.go @@ -276,7 +276,7 @@ func (h *meHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) return } - if !user.IsActive || user.DeletedAt.Valid { + if user.Status != "active" { w.WriteHeader(http.StatusNoContent) return } diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go index d87bc25..98aac78 100644 --- a/internal/api/handlers_oauth.go +++ b/internal/api/handlers_oauth.go @@ -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 } diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 23b4b53..f8a8b67 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -80,7 +80,7 @@ func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) { Email string `json:"email"` Name string `json:"name"` IsAdmin bool `json:"is_admin"` - IsActive bool `json:"is_active"` + Status string `json:"status"` CreatedAt string `json:"created_at"` TeamsJoined int32 `json:"teams_joined"` TeamsOwned int32 `json:"teams_owned"` @@ -93,7 +93,7 @@ func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) { Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin, - IsActive: u.IsActive, + Status: u.Status, CreatedAt: u.CreatedAt.Format(time.RFC3339), TeamsJoined: u.TeamsJoined, TeamsOwned: u.TeamsOwned, @@ -135,9 +135,14 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) { return } - if err := h.svc.SetUserActive(r.Context(), userID, req.Active); err != nil { - status, code, msg := serviceErrToHTTP(err) - writeError(w, status, code, msg) + newStatus := "active" + if !req.Active { + newStatus = "disabled" + } + + if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil { + httpStatus, code, msg := serviceErrToHTTP(err) + writeError(w, httpStatus, code, msg) return } diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go index b328e40..6122ce8 100644 --- a/internal/api/middleware_auth.go +++ b/internal/api/middleware_auth.go @@ -71,7 +71,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler writeError(w, http.StatusUnauthorized, "unauthorized", "user not found") return } - if !user.IsActive { + if user.Status != "active" { writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access") return } diff --git a/internal/api/middleware_jwt.go b/internal/api/middleware_jwt.go index c0f4260..1b28405 100644 --- a/internal/api/middleware_jwt.go +++ b/internal/api/middleware_jwt.go @@ -50,7 +50,7 @@ func requireJWT(secret []byte, queries *db.Queries) func(http.Handler) http.Hand writeError(w, http.StatusUnauthorized, "unauthorized", "user not found") return } - if !user.IsActive { + if user.Status != "active" { writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access") return } diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 0ad8cd2..f4c369d 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -16,6 +16,10 @@ paths: summary: Create a new account operationId: signup tags: [auth] + description: | + Creates an inactive user account and sends an activation email. + The user must activate their account within 30 minutes. + Does not return a JWT — the user must activate first, then sign in. requestBody: required: true content: @@ -24,11 +28,11 @@ paths: $ref: "#/components/schemas/SignupRequest" responses: "201": - description: Account created + description: Account created, activation email sent content: application/json: schema: - $ref: "#/components/schemas/AuthResponse" + $ref: "#/components/schemas/SignupResponse" "400": description: Invalid request (bad email, short password) content: @@ -36,7 +40,39 @@ paths: schema: $ref: "#/components/schemas/Error" "409": - description: Email already registered + description: Email already registered or signup cooldown active + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + /v1/auth/activate: + post: + summary: Activate account via email token + operationId: activate + tags: [auth] + description: | + Consumes the activation token sent via email and activates the user account. + Creates a default team and returns a JWT to log the user in. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: + type: string + responses: + "200": + description: Account activated, JWT issued + content: + application/json: + schema: + $ref: "#/components/schemas/AuthResponse" + "400": + description: Invalid or expired token content: application/json: schema: @@ -229,7 +265,7 @@ paths: security: - bearerAuth: [] description: | - Soft-deletes the account (sets is_active=false, deleted_at=now). + Soft-deletes the account (sets status=deleted, deleted_at=now). The account is permanently removed after 15 days. Blocked if the user owns any team that has other members. requestBody: @@ -2323,6 +2359,13 @@ components: password: type: string + SignupResponse: + type: object + properties: + message: + type: string + description: Confirmation message instructing user to check email + AuthResponse: type: object properties: diff --git a/internal/api/server.go b/internal/api/server.go index fc2c3d5..aaeddcf 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -70,7 +70,7 @@ func New( filesStream := newFilesStreamHandler(queries, pool) fsH := newFSHandler(queries, pool) snapshots := newSnapshotHandler(templateSvc, queries, pool, al) - authH := newAuthHandler(queries, pgPool, jwtSecret, mailer) + authH := newAuthHandler(queries, pgPool, jwtSecret, mailer, rdb, oauthRedirectURL) oauthH := newOAuthHandler(queries, pgPool, jwtSecret, oauthRegistry, oauthRedirectURL) apiKeys := newAPIKeyHandler(apiKeySvc, al) hostH := newHostHandler(hostSvc, queries, al) @@ -93,6 +93,7 @@ func New( // Unauthenticated auth endpoints. r.Post("/v1/auth/signup", authH.Signup) r.Post("/v1/auth/login", authH.Login) + r.Post("/v1/auth/activate", authH.Activate) r.Get("/auth/oauth/{provider}", oauthH.Redirect) r.Get("/auth/oauth/{provider}/callback", oauthH.Callback) diff --git a/pkg/db/models.go b/pkg/db/models.go index 5e1128a..3111952 100644 --- a/pkg/db/models.go +++ b/pkg/db/models.go @@ -200,8 +200,8 @@ type User struct { IsAdmin bool `json:"is_admin"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` - IsActive bool `json:"is_active"` DeletedAt pgtype.Timestamptz `json:"deleted_at"` + Status string `json:"status"` } type UsersTeam struct { diff --git a/pkg/db/users.sql.go b/pkg/db/users.sql.go index 9b6e9f9..2bfb6ed 100644 --- a/pkg/db/users.sql.go +++ b/pkg/db/users.sql.go @@ -11,6 +11,17 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countActiveUsers = `-- name: CountActiveUsers :one +SELECT COUNT(*) FROM users WHERE status = 'active' +` + +func (q *Queries) CountActiveUsers(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, countActiveUsers) + var count int64 + err := row.Scan(&count) + return count, err +} + const countUserOwnedTeamsWithOtherMembers = `-- name: CountUserOwnedTeamsWithOtherMembers :one SELECT COUNT(DISTINCT ut.team_id)::int FROM users_teams ut @@ -97,7 +108,7 @@ func (q *Queries) GetAdminPermissions(ctx context.Context, userID pgtype.UUID) ( } const getAdminUsers = `-- name: GetAdminUsers :many -SELECT id, email, password_hash, name, is_admin, created_at, updated_at, is_active, deleted_at FROM users WHERE is_admin = TRUE ORDER BY created_at +SELECT id, email, password_hash, name, is_admin, created_at, updated_at, deleted_at, status FROM users WHERE is_admin = TRUE ORDER BY created_at ` func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { @@ -117,8 +128,8 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsActive, &i.DeletedAt, + &i.Status, ); err != nil { return nil, err } @@ -131,7 +142,7 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, email, password_hash, name, is_admin, created_at, updated_at, is_active, deleted_at FROM users WHERE email = $1 +SELECT id, email, password_hash, name, is_admin, created_at, updated_at, deleted_at, status FROM users WHERE email = $1 ` func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { @@ -145,14 +156,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsActive, &i.DeletedAt, + &i.Status, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, email, password_hash, name, is_admin, created_at, updated_at, is_active, deleted_at FROM users WHERE id = $1 +SELECT id, email, password_hash, name, is_admin, created_at, updated_at, deleted_at, status FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { @@ -166,8 +177,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsActive, &i.DeletedAt, + &i.Status, ) return i, err } @@ -181,6 +192,15 @@ func (q *Queries) HardDeleteExpiredUsers(ctx context.Context) error { return err } +const hardDeleteUser = `-- name: HardDeleteUser :exec +DELETE FROM users WHERE id = $1 +` + +func (q *Queries) HardDeleteUser(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, hardDeleteUser, id) + return err +} + const hasAdminPermission = `-- name: HasAdminPermission :one SELECT EXISTS( SELECT 1 FROM admin_permissions WHERE user_id = $1 AND permission = $2 @@ -218,7 +238,7 @@ func (q *Queries) InsertAdminPermission(ctx context.Context, arg InsertAdminPerm const insertUser = `-- name: InsertUser :one INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4) -RETURNING id, email, password_hash, name, is_admin, created_at, updated_at, is_active, deleted_at +RETURNING id, email, password_hash, name, is_admin, created_at, updated_at, deleted_at, status ` type InsertUserParams struct { @@ -244,8 +264,43 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsActive, &i.DeletedAt, + &i.Status, + ) + return i, err +} + +const insertUserInactive = `-- name: InsertUserInactive :one +INSERT INTO users (id, email, password_hash, name, status) +VALUES ($1, $2, $3, $4, 'inactive') +RETURNING id, email, password_hash, name, is_admin, created_at, updated_at, deleted_at, status +` + +type InsertUserInactiveParams struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash pgtype.Text `json:"password_hash"` + Name string `json:"name"` +} + +func (q *Queries) InsertUserInactive(ctx context.Context, arg InsertUserInactiveParams) (User, error) { + row := q.db.QueryRow(ctx, insertUserInactive, + arg.ID, + arg.Email, + arg.PasswordHash, + arg.Name, + ) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.PasswordHash, + &i.Name, + &i.IsAdmin, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + &i.Status, ) return i, err } @@ -253,7 +308,7 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e const insertUserOAuth = `-- name: InsertUserOAuth :one INSERT INTO users (id, email, name) VALUES ($1, $2, $3) -RETURNING id, email, password_hash, name, is_admin, created_at, updated_at, is_active, deleted_at +RETURNING id, email, password_hash, name, is_admin, created_at, updated_at, deleted_at, status ` type InsertUserOAuthParams struct { @@ -273,8 +328,8 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, - &i.IsActive, &i.DeletedAt, + &i.Status, ) return i, err } @@ -285,7 +340,7 @@ SELECT u.email, u.name, u.is_admin, - u.is_active, + u.status, u.created_at, (SELECT COUNT(*) FROM users_teams ut WHERE ut.user_id = u.id)::int AS teams_joined, (SELECT COUNT(*) FROM users_teams ut WHERE ut.user_id = u.id AND ut.role = 'owner')::int AS teams_owned @@ -305,7 +360,7 @@ type ListUsersAdminRow struct { Email string `json:"email"` Name string `json:"name"` IsAdmin bool `json:"is_admin"` - IsActive bool `json:"is_active"` + Status string `json:"status"` CreatedAt pgtype.Timestamptz `json:"created_at"` TeamsJoined int32 `json:"teams_joined"` TeamsOwned int32 `json:"teams_owned"` @@ -325,7 +380,7 @@ func (q *Queries) ListUsersAdmin(ctx context.Context, arg ListUsersAdminParams) &i.Email, &i.Name, &i.IsAdmin, - &i.IsActive, + &i.Status, &i.CreatedAt, &i.TeamsJoined, &i.TeamsOwned, @@ -369,20 +424,6 @@ func (q *Queries) SearchUsersByEmailPrefix(ctx context.Context, dollar_1 pgtype. return items, nil } -const setUserActive = `-- name: SetUserActive :exec -UPDATE users SET is_active = $2, updated_at = NOW() WHERE id = $1 -` - -type SetUserActiveParams struct { - ID pgtype.UUID `json:"id"` - IsActive bool `json:"is_active"` -} - -func (q *Queries) SetUserActive(ctx context.Context, arg SetUserActiveParams) error { - _, err := q.db.Exec(ctx, setUserActive, arg.ID, arg.IsActive) - return err -} - const setUserAdmin = `-- name: SetUserAdmin :exec UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1 ` @@ -397,8 +438,22 @@ func (q *Queries) SetUserAdmin(ctx context.Context, arg SetUserAdminParams) erro return err } +const setUserStatus = `-- name: SetUserStatus :exec +UPDATE users SET status = $2, updated_at = NOW() WHERE id = $1 +` + +type SetUserStatusParams struct { + ID pgtype.UUID `json:"id"` + Status string `json:"status"` +} + +func (q *Queries) SetUserStatus(ctx context.Context, arg SetUserStatusParams) error { + _, err := q.db.Exec(ctx, setUserStatus, arg.ID, arg.Status) + return err +} + const softDeleteUser = `-- name: SoftDeleteUser :exec -UPDATE users SET deleted_at = NOW(), is_active = false, updated_at = NOW() WHERE id = $1 +UPDATE users SET deleted_at = NOW(), status = 'deleted', updated_at = NOW() WHERE id = $1 ` func (q *Queries) SoftDeleteUser(ctx context.Context, id pgtype.UUID) error { diff --git a/pkg/service/user.go b/pkg/service/user.go index b585e23..db687c0 100644 --- a/pkg/service/user.go +++ b/pkg/service/user.go @@ -21,7 +21,7 @@ type AdminUserRow struct { Email string Name string IsAdmin bool - IsActive bool + Status string CreatedAt time.Time TeamsJoined int32 TeamsOwned int32 @@ -49,7 +49,7 @@ func (s *UserService) AdminListUsers(ctx context.Context, limit, offset int32) ( Email: u.Email, Name: u.Name, IsAdmin: u.IsAdmin, - IsActive: u.IsActive, + Status: u.Status, CreatedAt: u.CreatedAt.Time, TeamsJoined: u.TeamsJoined, TeamsOwned: u.TeamsOwned, @@ -58,13 +58,13 @@ func (s *UserService) AdminListUsers(ctx context.Context, limit, offset int32) ( return rows, total, nil } -// SetUserActive enables or disables a user account. -func (s *UserService) SetUserActive(ctx context.Context, userID pgtype.UUID, active bool) error { - if err := s.DB.SetUserActive(ctx, db.SetUserActiveParams{ - ID: userID, - IsActive: active, +// SetUserStatus sets the status of a user account. +func (s *UserService) SetUserStatus(ctx context.Context, userID pgtype.UUID, status string) error { + if err := s.DB.SetUserStatus(ctx, db.SetUserStatusParams{ + ID: userID, + Status: status, }); err != nil { - return fmt.Errorf("set user active: %w", err) + return fmt.Errorf("set user status: %w", err) } return nil }