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

@ -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;

View File

@ -14,6 +14,11 @@ INSERT INTO users (id, email, name)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING *; RETURNING *;
-- name: InsertUserInactive :one
INSERT INTO users (id, email, password_hash, name, status)
VALUES ($1, $2, $3, $4, 'inactive')
RETURNING *;
-- name: SetUserAdmin :exec -- name: SetUserAdmin :exec
UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1; UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1;
@ -38,6 +43,9 @@ SELECT EXISTS(
-- name: CountUsers :one -- name: CountUsers :one
SELECT COUNT(*) FROM users; SELECT COUNT(*) FROM users;
-- name: CountActiveUsers :one
SELECT COUNT(*) FROM users WHERE status = 'active';
-- name: SearchUsersByEmailPrefix :many -- name: SearchUsersByEmailPrefix :many
SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10; SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10;
@ -50,7 +58,7 @@ SELECT
u.email, u.email,
u.name, u.name,
u.is_admin, u.is_admin,
u.is_active, u.status,
u.created_at, 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)::int AS teams_joined,
(SELECT COUNT(*) FROM users_teams ut WHERE ut.user_id = u.id AND ut.role = 'owner')::int AS teams_owned (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 FROM users
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL;
-- name: SetUserActive :exec -- name: SetUserStatus :exec
UPDATE users SET is_active = $2, updated_at = NOW() WHERE id = $1; UPDATE users SET status = $2, updated_at = NOW() WHERE id = $1;
-- name: UpdateUserPassword :exec -- name: UpdateUserPassword :exec
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1; UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1;
-- name: SoftDeleteUser :exec -- 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 -- name: CountUserOwnedTeamsWithOtherMembers :one
SELECT COUNT(DISTINCT ut.team_id)::int SELECT COUNT(DISTINCT ut.team_id)::int
@ -85,3 +93,6 @@ WHERE ut.user_id = $1
-- name: HardDeleteExpiredUsers :exec -- name: HardDeleteExpiredUsers :exec
DELETE FROM users WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '15 days'; 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;

View File

@ -5,7 +5,7 @@ export type AdminUser = {
email: string; email: string;
name: string; name: string;
is_admin: boolean; is_admin: boolean;
is_active: boolean; status: string;
created_at: string; created_at: string;
teams_joined: number; teams_joined: number;
teams_owned: number; teams_owned: number;

View File

@ -6,17 +6,26 @@ export type AuthResponse = {
name: string; name: string;
}; };
export type SignupResponse = {
message: string;
};
export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: 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<AuthResult> { export async function apiLogin(email: string, password: string): Promise<AuthResult> {
return authFetch('/api/v1/auth/login', { email, password }); return authFetch('/api/v1/auth/login', { email, password });
} }
export async function apiSignup(email: string, password: string, name: string): Promise<AuthResult> { export async function apiSignup(email: string, password: string, name: string): Promise<SignupResult> {
return authFetch('/api/v1/auth/signup', { email, password, name }); return authFetch('/api/v1/auth/signup', { email, password, name });
} }
async function authFetch(url: string, body: Record<string, string>): Promise<AuthResult> { export async function apiActivate(token: string): Promise<AuthResult> {
return authFetch('/api/v1/auth/activate', { token });
}
async function authFetch<T = AuthResponse>(url: string, body: Record<string, string>): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
@ -31,7 +40,7 @@ async function authFetch(url: string, body: Record<string, string>): Promise<Aut
return { ok: false, error: message }; return { ok: false, error: message };
} }
return { ok: true, data: data as AuthResponse }; return { ok: true, data: data as T };
} catch { } catch {
return { ok: false, error: 'Unable to connect to the server' }; return { ok: false, error: 'Unable to connect to the server' };
} }

View File

@ -0,0 +1,75 @@
<script lang="ts">
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');
});
</script>
<svelte:head>
<title>Wrenn — Activate account</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[var(--color-bg-0)] px-4">
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease both">
<!-- Brand -->
<div class="mb-8 flex items-center gap-3">
<img src="/logo.svg" alt="Wrenn" class="h-10 w-10 rounded-[var(--radius-logo)]" />
<span class="font-brand text-page text-[var(--color-text-bright)]">Wrenn</span>
</div>
{#if loading}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
<div class="flex items-center gap-3">
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-accent)]/30 border-t-[var(--color-accent)]"></span>
<p class="text-body text-[var(--color-text-secondary)]">Activating your account...</p>
</div>
</div>
{:else if error}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
<h1 class="font-serif text-display text-[var(--color-text-bright)]">Activation failed</h1>
<p class="mt-2 text-ui text-[var(--color-red)]">{error}</p>
<a
href="/login"
class="mt-6 flex w-full items-center justify-center rounded-[var(--radius-button)] bg-[var(--color-accent)] py-3 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
>
Back to sign in
</a>
</div>
{:else if done}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
<div class="flex items-center gap-3">
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-accent)]/30 border-t-[var(--color-accent)]"></span>
<p class="text-body text-[var(--color-text-secondary)]">Redirecting to dashboard...</p>
</div>
</div>
{/if}
</div>
</div>

View File

@ -52,10 +52,10 @@
async function handleToggleActive(user: AdminUser) { async function handleToggleActive(user: AdminUser) {
togglingId = user.id; togglingId = user.id;
const newActive = !user.is_active; const newActive = user.status !== 'active';
const result = await setUserActive(user.id, newActive); const result = await setUserActive(user.id, newActive);
if (result.ok) { if (result.ok) {
user.is_active = newActive; user.status = newActive ? 'active' : 'disabled';
toast.success(`${user.email} ${newActive ? 'activated' : 'deactivated'}`); toast.success(`${user.email} ${newActive ? 'activated' : 'deactivated'}`);
} else { } else {
toast.error(result.error); toast.error(result.error);
@ -195,11 +195,11 @@
{:else} {:else}
{#each users as user, i (user.id)} {#each users as user, i (user.id)}
<div <div
class="user-row user-grid relative items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 last:border-b-0 {!user.is_active ? 'opacity-50' : 'hover:bg-[var(--color-bg-3)]'}" class="user-row user-grid relative items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 last:border-b-0 {user.status !== 'active' ? 'opacity-50' : 'hover:bg-[var(--color-bg-3)]'}"
style={initialAnimationDone ? '' : `animation: fadeUp 0.35s ease both; animation-delay: ${i * 30}ms`} style={initialAnimationDone ? '' : `animation: fadeUp 0.35s ease both; animation-delay: ${i * 30}ms`}
> >
<!-- Left accent stripe --> <!-- Left accent stripe -->
{#if user.is_active} {#if user.status === 'active'}
<div class="row-stripe pointer-events-none absolute left-0 top-0 h-full w-0.5 bg-[var(--color-accent)]"></div> <div class="row-stripe pointer-events-none absolute left-0 top-0 h-full w-0.5 bg-[var(--color-accent)]"></div>
{/if} {/if}
@ -247,14 +247,14 @@
onclick={() => handleToggleActive(user)} onclick={() => handleToggleActive(user)}
disabled={togglingId === user.id} 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 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-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'}" : '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} {#if togglingId === user.id}
<svg class="inline animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg> <svg class="inline animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
{:else} {:else}
{user.is_active ? 'Active' : 'Inactive'} {user.status === 'active' ? 'Active' : user.status.charAt(0).toUpperCase() + user.status.slice(1)}
{/if} {/if}
</button> </button>
</div> </div>

View File

@ -17,10 +17,12 @@
let mode: 'signin' | 'signup' = $state('signin'); let mode: 'signin' | 'signup' = $state('signin');
let email = $state(''); let email = $state('');
let password = $state(''); let password = $state('');
let confirmPassword = $state('');
let name = $state(''); let name = $state('');
let showPassword = $state(false); let showPassword = $state(false);
let error = $state(''); let error = $state('');
let loading = $state(false); let loading = $state(false);
let signupDone = $state(false);
const oauthErrorMessages: Record<string, string> = { const oauthErrorMessages: Record<string, string> = {
account_deactivated: 'Your account has been deactivated — contact your administrator to regain access', account_deactivated: 'Your account has been deactivated — contact your administrator to regain access',
@ -90,6 +92,8 @@
mode = mode === 'signin' ? 'signup' : 'signin'; mode = mode === 'signin' ? 'signup' : 'signin';
error = ''; error = '';
name = ''; name = '';
confirmPassword = '';
signupDone = false;
} }
async function handleSubmit(e: Event) { async function handleSubmit(e: Event) {
@ -97,11 +101,32 @@
error = ''; error = '';
loading = true; loading = true;
const result = if (mode === 'signup') {
mode === 'signin' if (password !== confirmPassword) {
? await apiLogin(email, password) error = 'Passwords do not match.';
: await apiSignup(email, password, name); 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; loading = false;
if (!result.ok) { if (!result.ok) {
@ -192,6 +217,25 @@
</div> </div>
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease 0.1s both"> <div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease 0.1s both">
{#if signupDone}
<!-- Post-signup confirmation -->
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6" style="animation: fadeUp 0.3s ease both">
<h2 class="font-serif text-display text-[var(--color-text-bright)]">Check your email</h2>
<p class="mt-2 text-body text-[var(--color-text-secondary)]">
We've sent an activation link to <span class="font-medium text-[var(--color-text-bright)]">{email}</span>. Click the link to activate your account.
</p>
<p class="mt-4 text-ui text-[var(--color-text-tertiary)]">
The link expires in 30 minutes. If you don't see it, check your spam folder.
</p>
<button
type="button"
onclick={switchMode}
class="mt-6 w-full rounded-[var(--radius-button)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-4 py-3 text-body font-medium text-[var(--color-text-bright)] transition-all duration-150 hover:border-[var(--color-accent)]"
>
Back to sign in
</button>
</div>
{:else}
<!-- Header --> <!-- Header -->
<div class="mb-8"> <div class="mb-8">
<h2 <h2
@ -283,6 +327,23 @@
</button> </button>
</div> </div>
{#if mode === 'signup'}
<div class="group relative">
<div
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"
>
<IconLock size={14} />
</div>
<input
type="password"
bind:value={confirmPassword}
placeholder="Confirm password"
autocomplete="new-password"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-3 text-body text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
/>
</div>
{/if}
{#if mode === 'signin'} {#if mode === 'signin'}
<div class="flex justify-end"> <div class="flex justify-end">
<a <a
@ -327,6 +388,7 @@
{switchAction} {switchAction}
</button> </button>
</p> </p>
{/if}
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,15 +2,21 @@ package api
import ( import (
"context" "context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"git.omukk.dev/wrenn/wrenn/internal/email" "git.omukk.dev/wrenn/wrenn/internal/email"
"git.omukk.dev/wrenn/wrenn/pkg/auth" "git.omukk.dev/wrenn/wrenn/pkg/auth"
@ -18,6 +24,12 @@ import (
"git.omukk.dev/wrenn/wrenn/pkg/id" "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. // 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 // 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 // back to the earliest-joined team. Returns pgx.ErrNoRows when the user has
@ -53,6 +65,74 @@ func loginTeam(ctx context.Context, q *db.Queries, userID pgtype.UUID) (db.Team,
}, first.Role, nil }, 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 { type switchTeamRequest struct {
TeamID string `json:"team_id"` TeamID string `json:"team_id"`
} }
@ -62,10 +142,12 @@ type authHandler struct {
pool *pgxpool.Pool pool *pgxpool.Pool
jwtSecret []byte jwtSecret []byte
mailer email.Mailer mailer email.Mailer
rdb *redis.Client
redirectURL string
} }
func newAuthHandler(db *db.Queries, pool *pgxpool.Pool, jwtSecret []byte, mailer email.Mailer) *authHandler { 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} return &authHandler{db: db, pool: pool, jwtSecret: jwtSecret, mailer: mailer, rdb: rdb, redirectURL: strings.TrimRight(redirectURL, "/")}
} }
type signupRequest struct { type signupRequest struct {
@ -79,6 +161,10 @@ type loginRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
type activateRequest struct {
Token string `json:"token"`
}
type authResponse struct { type authResponse struct {
Token string `json:"token"` Token string `json:"token"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
@ -87,6 +173,10 @@ type authResponse struct {
Name string `json:"name"` Name string `json:"name"`
} }
type signupResponse struct {
Message string `json:"message"`
}
// Signup handles POST /v1/auth/signup. // Signup handles POST /v1/auth/signup.
func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) { func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
var req signupRequest var req signupRequest
@ -112,32 +202,41 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() 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) passwordHash, err := auth.HashPassword(req.Password)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password") writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
return 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() userID := id.NewUserID()
_, err = qtx.InsertUser(ctx, db.InsertUserParams{ _, err = h.db.InsertUserInactive(ctx, db.InsertUserInactiveParams{
ID: userID, ID: userID,
Email: req.Email, Email: req.Email,
PasswordHash: pgtype.Text{String: passwordHash, Valid: true}, PasswordHash: pgtype.Text{String: passwordHash, Valid: true},
@ -153,61 +252,111 @@ func (h *authHandler) Signup(w http.ResponseWriter, r *http.Request) {
return return
} }
if isFirstUser { // Generate activation token and store in Redis.
if err := qtx.SetUserAdmin(ctx, db.SetUserAdminParams{ID: userID, IsAdmin: true}); err != nil { rawToken := generateActivationToken()
writeError(w, http.StatusInternalServerError, "db_error", "failed to set admin status") 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 return
} }
}
// Create default team. activateURL := h.redirectURL + "/activate?token=" + rawToken
teamID := id.NewTeamID() go func() {
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{ sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ID: teamID, defer cancel()
Name: req.Name + "'s Team", if err := h.mailer.Send(sendCtx, req.Email, "Activate your Wrenn account", email.EmailData{
Slug: id.NewTeamSlug(), 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 { }); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to create team") 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 return
} }
if err := qtx.InsertTeamMember(ctx, db.InsertTeamMemberParams{ if req.Token == "" {
UserID: userID, writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
TeamID: teamID, return
IsDefault: true, }
Role: "owner",
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 { }); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to add user to 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 return
} }
if err := tx.Commit(ctx); err != nil { // Create default team and log them in.
writeError(w, http.StatusInternalServerError, "db_error", "failed to commit signup") 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 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return return
} }
go func() { writeJSON(w, http.StatusOK, authResponse{
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{
Token: token, Token: token,
UserID: id.FormatUserID(userID), UserID: id.FormatUserID(userID),
TeamID: id.FormatTeamID(teamID), TeamID: id.FormatTeamID(team.ID),
Email: req.Email, Email: user.Email,
Name: req.Name, Name: user.Name,
}) })
} }
@ -249,23 +398,36 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
if !user.IsActive { switch user.Status {
slog.Warn("login failed: account deactivated", "email", req.Email, "ip", r.RemoteAddr) case "active":
writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access") // 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 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 err != nil {
if errors.Is(err, pgx.ErrNoRows) { slog.Error("login: failed to ensure default team", "error", err)
writeError(w, http.StatusForbidden, "no_team", "user is not a member of any team")
return
}
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team") writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
return 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 { if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token") writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return return
@ -355,3 +517,18 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
Name: user.Name, 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[:])
}

View File

@ -276,7 +276,7 @@ func (h *meHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request)
return return
} }
if !user.IsActive || user.DeletedAt.Valid { if user.Status != "active" {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return
} }

View File

@ -217,8 +217,8 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
redirectWithError(w, r, redirectBase, "db_error") redirectWithError(w, r, redirectBase, "db_error")
return return
} }
if !user.IsActive { if user.Status != "active" {
slog.Warn("oauth login: account deactivated", "email", user.Email) slog.Warn("oauth login: account not active", "email", user.Email, "status", user.Status)
redirectWithError(w, r, redirectBase, "account_deactivated") redirectWithError(w, r, redirectBase, "account_deactivated")
return return
} }
@ -244,13 +244,21 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
} }
// New OAuth identity — check for email collision. // New OAuth identity — check for email collision.
_, err = h.db.GetUserByEmail(ctx, email) existingUser, err := h.db.GetUserByEmail(ctx, email)
if err == nil { if err == nil {
// Email already taken by another account. 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") redirectWithError(w, r, redirectBase, "email_taken")
return return
} }
if !errors.Is(err, pgx.ErrNoRows) { } else if !errors.Is(err, pgx.ErrNoRows) {
slog.Error("oauth: email check failed", "error", err) slog.Error("oauth: email check failed", "error", err)
redirectWithError(w, r, redirectBase, "db_error") redirectWithError(w, r, redirectBase, "db_error")
return return
@ -373,8 +381,8 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov
redirectWithError(w, r, redirectBase, "db_error") redirectWithError(w, r, redirectBase, "db_error")
return return
} }
if !user.IsActive { if user.Status != "active" {
slog.Warn("oauth: retry login: account deactivated", "email", user.Email) slog.Warn("oauth: retry login: account not active", "email", user.Email, "status", user.Status)
redirectWithError(w, r, redirectBase, "account_deactivated") redirectWithError(w, r, redirectBase, "account_deactivated")
return return
} }

View File

@ -80,7 +80,7 @@ func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
Email string `json:"email"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
IsActive bool `json:"is_active"` Status string `json:"status"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
TeamsJoined int32 `json:"teams_joined"` TeamsJoined int32 `json:"teams_joined"`
TeamsOwned int32 `json:"teams_owned"` TeamsOwned int32 `json:"teams_owned"`
@ -93,7 +93,7 @@ func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
Email: u.Email, Email: u.Email,
Name: u.Name, Name: u.Name,
IsAdmin: u.IsAdmin, IsAdmin: u.IsAdmin,
IsActive: u.IsActive, Status: u.Status,
CreatedAt: u.CreatedAt.Format(time.RFC3339), CreatedAt: u.CreatedAt.Format(time.RFC3339),
TeamsJoined: u.TeamsJoined, TeamsJoined: u.TeamsJoined,
TeamsOwned: u.TeamsOwned, TeamsOwned: u.TeamsOwned,
@ -135,9 +135,14 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := h.svc.SetUserActive(r.Context(), userID, req.Active); err != nil { newStatus := "active"
status, code, msg := serviceErrToHTTP(err) if !req.Active {
writeError(w, status, code, msg) newStatus = "disabled"
}
if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil {
httpStatus, code, msg := serviceErrToHTTP(err)
writeError(w, httpStatus, code, msg)
return return
} }

View File

@ -71,7 +71,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
writeError(w, http.StatusUnauthorized, "unauthorized", "user not found") writeError(w, http.StatusUnauthorized, "unauthorized", "user not found")
return 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") writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access")
return return
} }

View File

@ -50,7 +50,7 @@ func requireJWT(secret []byte, queries *db.Queries) func(http.Handler) http.Hand
writeError(w, http.StatusUnauthorized, "unauthorized", "user not found") writeError(w, http.StatusUnauthorized, "unauthorized", "user not found")
return 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") writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access")
return return
} }

View File

@ -16,6 +16,10 @@ paths:
summary: Create a new account summary: Create a new account
operationId: signup operationId: signup
tags: [auth] 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: requestBody:
required: true required: true
content: content:
@ -24,11 +28,11 @@ paths:
$ref: "#/components/schemas/SignupRequest" $ref: "#/components/schemas/SignupRequest"
responses: responses:
"201": "201":
description: Account created description: Account created, activation email sent
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/AuthResponse" $ref: "#/components/schemas/SignupResponse"
"400": "400":
description: Invalid request (bad email, short password) description: Invalid request (bad email, short password)
content: content:
@ -36,7 +40,39 @@ paths:
schema: schema:
$ref: "#/components/schemas/Error" $ref: "#/components/schemas/Error"
"409": "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: content:
application/json: application/json:
schema: schema:
@ -229,7 +265,7 @@ paths:
security: security:
- bearerAuth: [] - bearerAuth: []
description: | 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 The account is permanently removed after 15 days. Blocked if the user
owns any team that has other members. owns any team that has other members.
requestBody: requestBody:
@ -2323,6 +2359,13 @@ components:
password: password:
type: string type: string
SignupResponse:
type: object
properties:
message:
type: string
description: Confirmation message instructing user to check email
AuthResponse: AuthResponse:
type: object type: object
properties: properties:

View File

@ -70,7 +70,7 @@ func New(
filesStream := newFilesStreamHandler(queries, pool) filesStream := newFilesStreamHandler(queries, pool)
fsH := newFSHandler(queries, pool) fsH := newFSHandler(queries, pool)
snapshots := newSnapshotHandler(templateSvc, queries, pool, al) 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) oauthH := newOAuthHandler(queries, pgPool, jwtSecret, oauthRegistry, oauthRedirectURL)
apiKeys := newAPIKeyHandler(apiKeySvc, al) apiKeys := newAPIKeyHandler(apiKeySvc, al)
hostH := newHostHandler(hostSvc, queries, al) hostH := newHostHandler(hostSvc, queries, al)
@ -93,6 +93,7 @@ func New(
// Unauthenticated auth endpoints. // Unauthenticated auth endpoints.
r.Post("/v1/auth/signup", authH.Signup) r.Post("/v1/auth/signup", authH.Signup)
r.Post("/v1/auth/login", authH.Login) 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}", oauthH.Redirect)
r.Get("/auth/oauth/{provider}/callback", oauthH.Callback) r.Get("/auth/oauth/{provider}/callback", oauthH.Callback)

View File

@ -200,8 +200,8 @@ type User struct {
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
IsActive bool `json:"is_active"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"` DeletedAt pgtype.Timestamptz `json:"deleted_at"`
Status string `json:"status"`
} }
type UsersTeam struct { type UsersTeam struct {

View File

@ -11,6 +11,17 @@ import (
"github.com/jackc/pgx/v5/pgtype" "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 const countUserOwnedTeamsWithOtherMembers = `-- name: CountUserOwnedTeamsWithOtherMembers :one
SELECT COUNT(DISTINCT ut.team_id)::int SELECT COUNT(DISTINCT ut.team_id)::int
FROM users_teams ut FROM users_teams ut
@ -97,7 +108,7 @@ func (q *Queries) GetAdminPermissions(ctx context.Context, userID pgtype.UUID) (
} }
const getAdminUsers = `-- name: GetAdminUsers :many 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) { 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.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt, &i.DeletedAt,
&i.Status,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -131,7 +142,7 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
} }
const getUserByEmail = `-- name: GetUserByEmail :one 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) { 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.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt, &i.DeletedAt,
&i.Status,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one 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) { 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.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt, &i.DeletedAt,
&i.Status,
) )
return i, err return i, err
} }
@ -181,6 +192,15 @@ func (q *Queries) HardDeleteExpiredUsers(ctx context.Context) error {
return err 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 const hasAdminPermission = `-- name: HasAdminPermission :one
SELECT EXISTS( SELECT EXISTS(
SELECT 1 FROM admin_permissions WHERE user_id = $1 AND permission = $2 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 const insertUser = `-- name: InsertUser :one
INSERT INTO users (id, email, password_hash, name) INSERT INTO users (id, email, password_hash, name)
VALUES ($1, $2, $3, $4) 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 { type InsertUserParams struct {
@ -244,8 +264,43 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt, &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 return i, err
} }
@ -253,7 +308,7 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e
const insertUserOAuth = `-- name: InsertUserOAuth :one const insertUserOAuth = `-- name: InsertUserOAuth :one
INSERT INTO users (id, email, name) INSERT INTO users (id, email, name)
VALUES ($1, $2, $3) 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 { type InsertUserOAuthParams struct {
@ -273,8 +328,8 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt, &i.DeletedAt,
&i.Status,
) )
return i, err return i, err
} }
@ -285,7 +340,7 @@ SELECT
u.email, u.email,
u.name, u.name,
u.is_admin, u.is_admin,
u.is_active, u.status,
u.created_at, 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)::int AS teams_joined,
(SELECT COUNT(*) FROM users_teams ut WHERE ut.user_id = u.id AND ut.role = 'owner')::int AS teams_owned (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"` Email string `json:"email"`
Name string `json:"name"` Name string `json:"name"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
IsActive bool `json:"is_active"` Status string `json:"status"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
TeamsJoined int32 `json:"teams_joined"` TeamsJoined int32 `json:"teams_joined"`
TeamsOwned int32 `json:"teams_owned"` TeamsOwned int32 `json:"teams_owned"`
@ -325,7 +380,7 @@ func (q *Queries) ListUsersAdmin(ctx context.Context, arg ListUsersAdminParams)
&i.Email, &i.Email,
&i.Name, &i.Name,
&i.IsAdmin, &i.IsAdmin,
&i.IsActive, &i.Status,
&i.CreatedAt, &i.CreatedAt,
&i.TeamsJoined, &i.TeamsJoined,
&i.TeamsOwned, &i.TeamsOwned,
@ -369,20 +424,6 @@ func (q *Queries) SearchUsersByEmailPrefix(ctx context.Context, dollar_1 pgtype.
return items, nil 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 const setUserAdmin = `-- name: SetUserAdmin :exec
UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1 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 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 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 { func (q *Queries) SoftDeleteUser(ctx context.Context, id pgtype.UUID) error {

View File

@ -21,7 +21,7 @@ type AdminUserRow struct {
Email string Email string
Name string Name string
IsAdmin bool IsAdmin bool
IsActive bool Status string
CreatedAt time.Time CreatedAt time.Time
TeamsJoined int32 TeamsJoined int32
TeamsOwned int32 TeamsOwned int32
@ -49,7 +49,7 @@ func (s *UserService) AdminListUsers(ctx context.Context, limit, offset int32) (
Email: u.Email, Email: u.Email,
Name: u.Name, Name: u.Name,
IsAdmin: u.IsAdmin, IsAdmin: u.IsAdmin,
IsActive: u.IsActive, Status: u.Status,
CreatedAt: u.CreatedAt.Time, CreatedAt: u.CreatedAt.Time,
TeamsJoined: u.TeamsJoined, TeamsJoined: u.TeamsJoined,
TeamsOwned: u.TeamsOwned, TeamsOwned: u.TeamsOwned,
@ -58,13 +58,13 @@ func (s *UserService) AdminListUsers(ctx context.Context, limit, offset int32) (
return rows, total, nil return rows, total, nil
} }
// SetUserActive enables or disables a user account. // SetUserStatus sets the status of a user account.
func (s *UserService) SetUserActive(ctx context.Context, userID pgtype.UUID, active bool) error { func (s *UserService) SetUserStatus(ctx context.Context, userID pgtype.UUID, status string) error {
if err := s.DB.SetUserActive(ctx, db.SetUserActiveParams{ if err := s.DB.SetUserStatus(ctx, db.SetUserStatusParams{
ID: userID, ID: userID,
IsActive: active, Status: status,
}); err != nil { }); err != nil {
return fmt.Errorf("set user active: %w", err) return fmt.Errorf("set user status: %w", err)
} }
return nil return nil
} }