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)
|
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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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' };
|
||||||
}
|
}
|
||||||
|
|||||||
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) {
|
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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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[:])
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user