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:
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<AuthResult> {
|
||||
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 });
|
||||
}
|
||||
|
||||
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 {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@ -31,7 +40,7 @@ async function authFetch(url: string, body: Record<string, string>): Promise<Aut
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
return { ok: true, data: data as AuthResponse };
|
||||
return { ok: true, data: data as T };
|
||||
} catch {
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
|
||||
75
frontend/src/routes/activate/+page.svelte
Normal file
75
frontend/src/routes/activate/+page.svelte
Normal 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>
|
||||
@ -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)}
|
||||
<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`}
|
||||
>
|
||||
<!-- 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>
|
||||
{/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}
|
||||
<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}
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
{user.status === 'active' ? 'Active' : user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -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<string, string> = {
|
||||
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 @@
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease 0.1s both">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h2
|
||||
class="font-serif text-display tracking-[0.01em] text-[var(--color-text-bright)]"
|
||||
{#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 -->
|
||||
<div class="mb-8">
|
||||
<h2
|
||||
class="font-serif text-display tracking-[0.01em] text-[var(--color-text-bright)]"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p class="mt-2 text-body text-[var(--color-text-secondary)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- GitHub OAuth -->
|
||||
<a
|
||||
href="/api/auth/oauth/github"
|
||||
class="flex w-full items-center justify-center gap-2.5 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)] no-underline transition-all duration-150 hover:border-[var(--color-accent)] hover:text-[var(--color-text-bright)]"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p class="mt-2 text-body text-[var(--color-text-secondary)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<IconGithub size={16} />
|
||||
Continue with GitHub
|
||||
</a>
|
||||
|
||||
<!-- GitHub OAuth -->
|
||||
<a
|
||||
href="/api/auth/oauth/github"
|
||||
class="flex w-full items-center justify-center gap-2.5 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)] no-underline transition-all duration-150 hover:border-[var(--color-accent)] hover:text-[var(--color-text-bright)]"
|
||||
>
|
||||
<IconGithub size={16} />
|
||||
Continue with GitHub
|
||||
</a>
|
||||
<!-- Divider -->
|
||||
<div class="my-6 flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-[var(--color-border)]"></div>
|
||||
<span
|
||||
class="font-mono text-badge uppercase tracking-[0.1em] text-[var(--color-text-muted)]"
|
||||
>or</span
|
||||
>
|
||||
<div class="h-px flex-1 bg-[var(--color-border)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-6 flex items-center gap-3">
|
||||
<div class="h-px flex-1 bg-[var(--color-border)]"></div>
|
||||
<span
|
||||
class="font-mono text-badge uppercase tracking-[0.1em] text-[var(--color-text-muted)]"
|
||||
>or</span
|
||||
>
|
||||
<div class="h-px flex-1 bg-[var(--color-border)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form onsubmit={handleSubmit} class="space-y-3">
|
||||
{#if mode === 'signup'}
|
||||
<!-- Form -->
|
||||
<form onsubmit={handleSubmit} class="space-y-3">
|
||||
{#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)]"
|
||||
>
|
||||
<IconUser size={14} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="Full name"
|
||||
autocomplete="name"
|
||||
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}
|
||||
<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)]"
|
||||
>
|
||||
<IconUser size={14} />
|
||||
<IconMail size={14} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="Full name"
|
||||
autocomplete="name"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email address"
|
||||
autocomplete="email"
|
||||
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}
|
||||
<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)]"
|
||||
>
|
||||
<IconMail size={14} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email address"
|
||||
autocomplete="email"
|
||||
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>
|
||||
|
||||
<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 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={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
autocomplete={mode === 'signin' ? 'current-password' : 'new-password'}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-10 text-body text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
|
||||
tabindex={-1}
|
||||
>
|
||||
{#if showPassword}
|
||||
<IconEyeOff size={14} />
|
||||
{:else}
|
||||
<IconEye size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
autocomplete={mode === 'signin' ? 'current-password' : 'new-password'}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-10 text-body text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
|
||||
/>
|
||||
|
||||
{#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'}
|
||||
<div class="flex justify-end">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-ui text-[var(--color-red)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
|
||||
tabindex={-1}
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="!mt-5 w-full rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-3 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{#if showPassword}
|
||||
<IconEyeOff size={14} />
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white"
|
||||
></span>
|
||||
{submitLabel}
|
||||
</span>
|
||||
{:else}
|
||||
<IconEye size={14} />
|
||||
{submitLabel}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if mode === 'signin'}
|
||||
<div class="flex justify-end">
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p class="text-ui text-[var(--color-red)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="!mt-5 w-full rounded-[var(--radius-button)] bg-[var(--color-accent)] px-4 py-3 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span
|
||||
class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white"
|
||||
></span>
|
||||
{submitLabel}
|
||||
</span>
|
||||
{:else}
|
||||
{submitLabel}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Switch mode -->
|
||||
<p class="mt-6 text-center text-ui text-[var(--color-text-secondary)]">
|
||||
{switchText}
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchMode}
|
||||
class="font-medium text-[var(--color-text-primary)] transition-colors duration-150 hover:text-[var(--color-text-bright)]"
|
||||
>
|
||||
{switchAction}
|
||||
</button>
|
||||
</p>
|
||||
<!-- Switch mode -->
|
||||
<p class="mt-6 text-center text-ui text-[var(--color-text-secondary)]">
|
||||
{switchText}
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchMode}
|
||||
class="font-medium text-[var(--color-text-primary)] transition-colors duration-150 hover:text-[var(--color-text-bright)]"
|
||||
>
|
||||
{switchAction}
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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[:])
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user