1
0
forked from wrenn/wrenn

Add email activation flow and replace is_active with status column

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

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

View File

@ -0,0 +1,75 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { auth } from '$lib/auth.svelte';
import { teams } from '$lib/teams.svelte';
import { apiActivate } from '$lib/api/auth';
let loading = $state(true);
let error = $state('');
let done = $state(false);
onMount(async () => {
const token = $page.url.searchParams.get('token');
if (!token) {
error = 'No activation token provided.';
loading = false;
return;
}
const result = await apiActivate(token);
loading = false;
if (!result.ok) {
error = result.error;
return;
}
done = true;
teams.reset();
auth.login(result.data);
goto('/dashboard');
});
</script>
<svelte:head>
<title>Wrenn — Activate account</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[var(--color-bg-0)] px-4">
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease both">
<!-- Brand -->
<div class="mb-8 flex items-center gap-3">
<img src="/logo.svg" alt="Wrenn" class="h-10 w-10 rounded-[var(--radius-logo)]" />
<span class="font-brand text-page text-[var(--color-text-bright)]">Wrenn</span>
</div>
{#if loading}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
<div class="flex items-center gap-3">
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-accent)]/30 border-t-[var(--color-accent)]"></span>
<p class="text-body text-[var(--color-text-secondary)]">Activating your account...</p>
</div>
</div>
{:else if error}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
<h1 class="font-serif text-display text-[var(--color-text-bright)]">Activation failed</h1>
<p class="mt-2 text-ui text-[var(--color-red)]">{error}</p>
<a
href="/login"
class="mt-6 flex w-full items-center justify-center rounded-[var(--radius-button)] bg-[var(--color-accent)] py-3 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
>
Back to sign in
</a>
</div>
{:else if done}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
<div class="flex items-center gap-3">
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-accent)]/30 border-t-[var(--color-accent)]"></span>
<p class="text-body text-[var(--color-text-secondary)]">Redirecting to dashboard...</p>
</div>
</div>
{/if}
</div>
</div>

View File

@ -52,10 +52,10 @@
async function handleToggleActive(user: AdminUser) {
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>

View File

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