1
0
forked from wrenn/wrenn

Merge pull request 'Added settings for users and proper email flow for authentication' (#30) from feat/user-onboarding into dev

Reviewed-on: wrenn/wrenn#30
This commit is contained in:
2026-04-15 22:45:30 +00:00
49 changed files with 3061 additions and 575 deletions

107
CLAUDE.md
View File

@ -64,7 +64,7 @@ envd is a **completely independent Go module**. It is never imported by the main
### Control Plane
**Internal packages:** `internal/api/`, `internal/dashboard/`
**Internal packages:** `internal/api/`, `internal/dashboard/`, `internal/email/`
**Public packages (importable by cloud repo):** `pkg/config/`, `pkg/db/`, `pkg/auth/`, `pkg/auth/oauth/`, `pkg/scheduler/`, `pkg/lifecycle/`, `pkg/channels/`, `pkg/audit/`, `pkg/service/`, `pkg/events/`, `pkg/id/`, `pkg/validate/`
@ -241,7 +241,9 @@ The main module (`go.mod`) and envd (`envd/go.mod`) are fully independent. `make
## Design Context
### Users
Developers across the full spectrum — solo engineers building side projects, startup teams integrating sandboxed execution into products, and platform/infra engineers at larger organizations. The interface must feel at home for all three: approachable enough not to intimidate a hacker, precise enough to earn the trust of a production ops team. Never condescend, never oversimplify. Trust the user to understand what they're looking at.
Developers across the full spectrum — solo engineers building side projects, startup teams integrating sandboxed execution into products, and platform/infra engineers at larger organizations running production workloads on Firecracker microVMs. They arrive with context: they know what a process is, what a rootfs is, what a TTY means. The interface must feel at home for all three: approachable enough not to intimidate a hacker, precise enough to earn the trust of a production ops team. Never condescend, never oversimplify. Trust the user to understand what they're looking at.
**Primary job to be done:** Understand what's running, act on it confidently, and get back to code.
### Brand Personality
**Precise. Warm. Uncompromising.**
@ -251,9 +253,9 @@ Wrenn is an engineer's favorite tool — built with visible care, not assembled
Emotional goal: **in control.** Users leave a session with full confidence in what's running, what happened, and what comes next. Nothing is hidden, nothing is ambiguous.
### Aesthetic Direction
**Dark-first, industrial-warm, data-forward.**
**Dark-only (permanently), industrial-warm, data-forward.**
The near-black-green background palette (`#0a0c0b` through `#2a302d`) reads as "black with intention" — not pitch black (cold) and not charcoal (dated). The sage green accent (`#5e8c58`) is muted and organic, a meaningful departure from the startup-green neon that saturates the developer tool space.
No light mode planned. All design decisions should optimize for dark. The near-black-green background palette (`#0a0c0b` through `#2a302d`) reads as "black with intention" — not pitch black (cold) and not charcoal (dated). The sage green accent (`#5e8c58`) is muted and organic, a meaningful departure from the startup-green neon that saturates the developer tool space.
**Anti-references:**
- **Supabase**: avoid the friendly, approachable startup-green energy — too generic, too eager to please
@ -267,12 +269,12 @@ The near-black-green background palette (`#0a0c0b` through `#2a302d`) reads as "
### Type System
Four fonts with strict roles — this is the design system's strongest personality trait and must be respected:
| Font | Role | When to use |
|------|------|-------------|
| **Manrope** (variable, sans) | UI workhorse | All body copy, nav, labels, buttons, form text |
| **Instrument Serif** | Display / editorial | Page titles (h1), dialog headings, metric values, hero moments |
| **JetBrains Mono** (variable) | Data / code | IDs, timestamps, key prefixes, file paths, terminal output, metrics |
| **Alice** | Brand wordmark | "Wrenn" in sidebar and login only — nowhere else |
| Font | CSS Class | Role | When to use |
|------|-----------|------|-------------|
| **Manrope** (variable, sans) | `font-sans` | UI workhorse | All body copy, nav, labels, buttons, form text |
| **Instrument Serif** | `font-serif` | Display / editorial | Page titles (h1), dialog headings, metric values, hero moments |
| **JetBrains Mono** (variable) | `font-mono` | Data / code | IDs, timestamps, key prefixes, file paths, terminal output, metrics |
| **Alice** | brand wordmark only | Brand wordmark | "Wrenn" in sidebar and login only — nowhere else |
Instrument Serif at scale creates the signature editorial moments. Mono provides the precision signal for technical data. Never swap these roles.
@ -280,21 +282,82 @@ Instrument Serif at scale creates the signature editorial moments. Mono provides
- `.font-serif``letter-spacing: 0.015em` (positive tracking; Instrument Serif reads less condensed at display sizes)
- `.font-mono``font-variant-numeric: tabular-nums` (numbers align in tables and metric displays)
**Type scale (root: 87.5% = 14px base):**
| Token | Value | Use |
|---|---|---|
| `--text-display` | 2.571rem (~36px) | Auth section headings |
| `--text-page` | 2rem (~28px) | Page h1 titles |
| `--text-heading` | 1.429rem (~20px) | Dialog headings, empty states |
| `--text-body` | 1rem (~14px) | Primary body, buttons, inputs |
| `--text-ui` | 0.929rem (~13px) | Nav labels, table cells |
| `--text-meta` | 0.857rem (~12px) | Key prefixes, minor info |
| `--text-label` | 0.786rem (~11px) | Uppercase section labels |
| `--text-badge` | 0.714rem (~10px) | Live badges, tiny indicators |
### Color System
```
Backgrounds: bg-0 (#0a0c0b) through bg-5 (#2a302d) — 6 steps
Text: bright > primary > secondary > tertiary > muted — 5 levels
Accent: accent (#5e8c58) / accent-mid / accent-bright / glow / glow-mid
Status: amber (#d4a73c) / red (#cf8172) / blue (#5a9fd4)
```
Use accent sparingly. It should feel earned — reserved for live/active state indicators, primary CTAs, focus rings, and active nav. When accent appears, it should register.
All values are CSS custom properties in `frontend/src/app.css`.
### Upcoming Surfaces (design must accommodate)
- **Terminal / shell output**: streaming exec output, TTY sessions. Needs strong mono treatment, high contrast for long sessions.
- **File browser**: filesystem tree inside capsule. Density matters — breadcrumbs, file icons, permission bits.
- **SDK / docs embedding**: code samples, quickstart flows inline in dashboard. Code blocks must feel premium, not afterthought.
- **Billing / usage charts**: pool consumption, cost curves, usage over time. Instrument Serif at large scale for metrics; chart containers should feel like instruments, not dashboards.
**Backgrounds (6-step near-black-green scale):**
| Token | Value | Use |
|---|---|---|
| `--color-bg-0` | `#0a0c0b` | Page base, sidebar deepest layer |
| `--color-bg-1` | `#0f1211` | Sidebar surface |
| `--color-bg-2` | `#141817` | Card backgrounds |
| `--color-bg-3` | `#1a1e1c` | Table headers, elevated surfaces |
| `--color-bg-4` | `#212624` | Hover states, inputs |
| `--color-bg-5` | `#2a302d` | Highlighted items, selected rows |
**Text (5-level hierarchy):**
| Token | Value | Use |
|---|---|---|
| `--color-text-bright` | `#eae7e2` | H1s, dialog headings |
| `--color-text-primary` | `#d0cdc6` | Body copy, primary labels |
| `--color-text-secondary` | `#9b9790` | Secondary labels, descriptions |
| `--color-text-tertiary` | `#6b6862` | Hints, placeholders |
| `--color-text-muted` | `#454340` | Dividers as text, ultra-subtle |
**Accent (sage green — use sparingly, must feel earned):**
| Token | Value | Use |
|---|---|---|
| `--color-accent` | `#5e8c58` | Primary CTA, live indicators, focus rings, active nav |
| `--color-accent-mid` | `#89a785` | Hover accent text |
| `--color-accent-bright` | `#a4c89f` | Accent on dark backgrounds |
| `--color-accent-glow` | `rgba(94,140,88,0.07)` | Subtle tinted backgrounds |
| `--color-accent-glow-mid` | `rgba(94,140,88,0.14)` | Hover tint on accent items |
**Status semantics:**
| Token | Value | Use |
|---|---|---|
| `--color-amber` | `#d4a73c` | Warning, paused state |
| `--color-red` | `#cf8172` | Error, destructive actions |
| `--color-blue` | `#5a9fd4` | Info, neutral system states |
**Borders:** `--color-border` (`#1f2321`) default; `--color-border-mid` (`#2a2f2c`) for inputs/hover.
### Component Patterns
**Buttons:**
- Primary: solid sage green (`--color-accent`), hover brightness boost + micro-lift (`-translate-y-px`)
- Secondary: bordered (`--color-border-mid`), text transitions to accent on hover
- Danger: red text + subtle red background on hover
- All: `transition-all duration-150`
**Inputs:**
- Border `--color-border`, background `--color-bg-2`; focus transitions border and icon to accent
- Group focus pattern: `group` wrapper + `group-focus-within:text-[var(--color-accent)]` on icon
**Tables / data lists:**
- Grid layout; header `bg-3` + uppercase `--text-label`; row hover `hover:bg-[var(--color-bg-3)]`
- Status stripe: left border color matches sandbox state
**Status indicators:** Running = animated ping + sage green dot; Paused = amber dot; Stopped = muted gray. Color is never the sole differentiator.
**Modals & dialogs:** Border + shadow only — no accent gradient bars/strips. `fadeUp` 0.35s entrance.
**Empty states:** Large icon with glow, Instrument Serif heading, secondary body text, CTA below, `iconFloat` 4s animation.
**Animations (always respect `prefers-reduced-motion`):** `fadeUp` (entrance), `status-ping` (live indicator), `iconFloat` (empty states), `spin-once` (refresh), staggered `animation-delay` on lists.
### Design Principles

View File

@ -0,0 +1,15 @@
-- +goose Up
ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
-- Backfill from existing columns.
UPDATE users SET status = 'deleted' WHERE deleted_at IS NOT NULL;
UPDATE users SET status = 'disabled' WHERE is_active = false AND deleted_at IS NULL;
ALTER TABLE users DROP COLUMN is_active;
-- +goose Down
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE;
UPDATE users SET is_active = false WHERE status IN ('inactive', 'disabled', 'deleted');
ALTER TABLE users DROP COLUMN status;

View File

@ -0,0 +1,72 @@
-- +goose Up
-- users_teams: remove membership when user is deleted
ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey;
ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- oauth_providers: remove auth links when user is deleted
ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey;
ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- admin_permissions: remove permissions when user is deleted
ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey;
ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
-- team_api_keys.created_by: make nullable, SET NULL on user delete
ALTER TABLE team_api_keys ALTER COLUMN created_by DROP NOT NULL;
ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey;
ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
-- hosts.created_by: make nullable, SET NULL on user delete
ALTER TABLE hosts ALTER COLUMN created_by DROP NOT NULL;
ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey;
ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
-- host_tokens.created_by: make nullable, SET NULL on user delete
ALTER TABLE host_tokens ALTER COLUMN created_by DROP NOT NULL;
ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey;
ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
-- +goose Down
-- Revert host_tokens.created_by
ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey;
UPDATE host_tokens SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
ALTER TABLE host_tokens ALTER COLUMN created_by SET NOT NULL;
ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id);
-- Revert hosts.created_by
ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey;
UPDATE hosts SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
ALTER TABLE hosts ALTER COLUMN created_by SET NOT NULL;
ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id);
-- Revert team_api_keys.created_by
ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey;
UPDATE team_api_keys SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
ALTER TABLE team_api_keys ALTER COLUMN created_by SET NOT NULL;
ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id);
-- Revert admin_permissions
ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey;
ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
-- Revert oauth_providers
ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey;
ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);
-- Revert users_teams
ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey;
ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users(id);

View File

@ -13,7 +13,7 @@ SELECT * FROM team_api_keys WHERE team_id = $1 ORDER BY created_at DESC;
SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used,
u.email AS creator_email
FROM team_api_keys k
JOIN users u ON u.id = k.created_by
LEFT JOIN users u ON u.id = k.created_by
WHERE k.team_id = $1
ORDER BY k.created_at DESC;
@ -22,3 +22,6 @@ DELETE FROM team_api_keys WHERE id = $1 AND team_id = $2;
-- name: UpdateAPIKeyLastUsed :exec
UPDATE team_api_keys SET last_used = NOW() WHERE id = $1;
-- name: DeleteAPIKeysByTeam :exec
DELETE FROM team_api_keys WHERE team_id = $1;

View File

@ -22,6 +22,9 @@ RETURNING *;
-- name: DeleteChannelByTeam :exec
DELETE FROM channels WHERE id = $1 AND team_id = $2;
-- name: DeleteAllChannelsByTeam :exec
DELETE FROM channels WHERE team_id = $1;
-- name: ListChannelsForEvent :many
SELECT * FROM channels
WHERE team_id = $1

View File

@ -51,6 +51,13 @@ WHERE sandbox_id = $1 AND tier = $2;
DELETE FROM sandbox_metric_points
WHERE ts < EXTRACT(EPOCH FROM NOW() - INTERVAL '30 days')::BIGINT;
-- name: DeleteMetricsSnapshotsByTeam :exec
DELETE FROM sandbox_metrics_snapshots WHERE team_id = $1;
-- name: DeleteMetricPointsByTeam :exec
DELETE FROM sandbox_metric_points
WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1);
-- name: SampleSandboxMetrics :many
-- Aggregates per-team resource usage from the live sandboxes table.
-- Groups by all teams that have any sandbox row (including stopped) so that

View File

@ -5,3 +5,9 @@ VALUES ($1, $2, $3, $4);
-- name: GetOAuthProvider :one
SELECT * FROM oauth_providers
WHERE provider = $1 AND provider_id = $2;
-- name: GetOAuthProvidersByUserID :many
SELECT * FROM oauth_providers WHERE user_id = $1;
-- name: DeleteOAuthProvider :exec
DELETE FROM oauth_providers WHERE user_id = $1 AND provider = $2;

View File

@ -62,7 +62,7 @@ WHERE id = ANY($1::uuid[]);
-- name: ListActiveSandboxesByTeam :many
SELECT * FROM sandboxes
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting')
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting', 'hibernated')
ORDER BY created_at DESC;
-- name: MarkSandboxesMissingByHost :exec

View File

@ -74,6 +74,18 @@ WHERE t.id != '00000000-0000-0000-0000-000000000000'
ORDER BY t.deleted_at ASC NULLS FIRST, t.created_at DESC
LIMIT $1 OFFSET $2;
-- name: ListSoleOwnedTeams :many
-- Returns teams where the user is the owner and no other members exist.
SELECT t.id FROM teams t
JOIN users_teams ut ON ut.team_id = t.id
WHERE ut.user_id = $1
AND ut.role = 'owner'
AND t.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM users_teams ut2
WHERE ut2.team_id = t.id AND ut2.user_id <> $1
);
-- name: CountTeamsAdmin :one
SELECT COUNT(*)::int AS total
FROM teams

View File

@ -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,5 +72,27 @@ 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(), status = 'deleted', updated_at = NOW() WHERE id = $1;
-- name: CountUserOwnedTeamsWithOtherMembers :one
SELECT COUNT(DISTINCT ut.team_id)::int
FROM users_teams ut
WHERE ut.user_id = $1
AND ut.role = 'owner'
AND EXISTS (
SELECT 1 FROM users_teams ut2
WHERE ut2.team_id = ut.team_id AND ut2.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;

View File

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

View File

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

View File

@ -0,0 +1,42 @@
import { apiFetch, type ApiResult } from '$lib/api/client';
import type { AuthResponse } from '$lib/api/auth';
export type MeResponse = {
name: string;
email: string;
has_password: boolean;
providers: string[];
};
export type ChangePasswordBody = {
current_password?: string;
new_password: string;
confirm_password?: string;
};
export const getMe = (): Promise<ApiResult<MeResponse>> =>
apiFetch('GET', '/api/v1/me');
export const updateName = (name: string): Promise<ApiResult<AuthResponse>> =>
apiFetch('PATCH', '/api/v1/me', { name });
export const changePassword = (body: ChangePasswordBody): Promise<ApiResult<void>> =>
apiFetch('POST', '/api/v1/me/password', body);
export const requestPasswordReset = (email: string): Promise<ApiResult<void>> =>
apiFetch('POST', '/api/v1/me/password/reset', { email });
export const confirmPasswordReset = (
token: string,
new_password: string
): Promise<ApiResult<void>> =>
apiFetch('POST', '/api/v1/me/password/reset/confirm', { token, new_password });
export const getProviderConnectURL = (provider: string): Promise<ApiResult<{ auth_url: string }>> =>
apiFetch('GET', `/api/v1/me/providers/${provider}/connect`);
export const disconnectProvider = (provider: string): Promise<ApiResult<void>> =>
apiFetch('DELETE', `/api/v1/me/providers/${provider}`);
export const deleteAccount = (confirmation: string): Promise<ApiResult<void>> =>
apiFetch('DELETE', '/api/v1/me', { confirmation });

View File

@ -280,13 +280,21 @@
<IconBell size={16} class="shrink-0" />
{#if !collapsed}<span class="text-ui">Notifications</span>{/if}
</div>
<div
class="flex cursor-not-allowed items-center rounded-[var(--radius-input)] px-2.5 py-2.5 opacity-35 {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? 'Settings (coming soon)' : 'Coming soon'}
<a
href="/dashboard/settings"
class="group relative flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'} {isActive('/dashboard/settings') ? (collapsed ? 'bg-[var(--color-accent-glow-mid)]' : 'bg-[var(--color-accent)]/[0.12]') : ''}"
title={collapsed ? 'Settings' : undefined}
>
<IconSettings size={16} class="shrink-0" />
{#if !collapsed}<span class="text-ui">Settings</span>{/if}
</div>
{#if isActive('/dashboard/settings') && !collapsed}
<div class="absolute left-0 top-1/2 h-6 w-1 -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
{/if}
<IconSettings size={16} class="shrink-0 {isActive('/dashboard/settings') ? 'text-[var(--color-accent-bright)]' : 'opacity-50 transition-opacity duration-150 group-hover:opacity-100'}" />
{#if !collapsed}
<span class="text-ui transition-colors duration-150 {isActive('/dashboard/settings') ? 'font-semibold text-[var(--color-accent-bright)]' : 'text-[var(--color-text-primary)] group-hover:text-[var(--color-text-bright)]'}">
Settings
</span>
{/if}
</a>
</div>
<!-- User footer -->

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

@ -0,0 +1,673 @@
<script lang="ts">
import Sidebar from '$lib/components/Sidebar.svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { auth } from '$lib/auth.svelte';
import { toast } from '$lib/toast.svelte';
import {
getMe,
updateName,
changePassword,
requestPasswordReset,
getProviderConnectURL,
disconnectProvider,
deleteAccount,
type MeResponse
} from '$lib/api/me';
let collapsed = $state(
typeof window !== 'undefined'
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
: false
);
let me = $state<MeResponse | null>(null);
let loadError = $state<string | null>(null);
let initials = $derived(
me?.name
? me.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
: me?.email?.[0]?.toUpperCase() ?? '?'
);
// Profile
let editName = $state('');
let savingName = $state(false);
let nameError = $state<string | null>(null);
let nameSaved = $state(false);
let nameSavedTimer: ReturnType<typeof setTimeout> | null = null;
// Password
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let savingPassword = $state(false);
let passwordError = $state<string | null>(null);
let sendingReset = $state(false);
let passwordSaved = $state(false);
let passwordSavedTimer: ReturnType<typeof setTimeout> | null = null;
// GitHub connect/disconnect
let connectingGitHub = $state(false);
let disconnectingGitHub = $state(false);
let showDisconnectConfirm = $state(false);
let disconnectError = $state<string | null>(null);
// Delete account
let showDeleteConfirm = $state(false);
let deleteConfirmation = $state('');
let deleting = $state(false);
let deleteError = $state<string | null>(null);
const connectErrors: Record<string, string> = {
already_linked: 'This GitHub account is already connected to another Wrenn account.',
db_error: 'Something went wrong — please try again.',
invalid_state: 'The connection attempt expired — please try again.',
access_denied: 'GitHub access was denied.',
exchange_failed: 'Authentication failed — please try again.'
};
async function fetchMe() {
const result = await getMe();
if (result.ok) {
me = result.data;
editName = result.data.name;
} else {
loadError = result.error;
}
}
async function handleSaveName() {
if (!editName.trim() || editName.trim() === me?.name) return;
savingName = true;
nameError = null;
const result = await updateName(editName.trim());
if (result.ok) {
auth.login(result.data);
me = { ...me!, name: result.data.name };
editName = result.data.name;
toast.success('Name updated.');
nameSaved = true;
if (nameSavedTimer) clearTimeout(nameSavedTimer);
nameSavedTimer = setTimeout(() => (nameSaved = false), 1500);
} else {
nameError = result.error;
}
savingName = false;
}
async function handleSendPasswordReset() {
if (!me) return;
sendingReset = true;
const result = await requestPasswordReset(me.email);
sendingReset = false;
if (result.ok) {
toast.success('Password reset link sent to your email.');
} else {
toast.error(result.error);
}
}
async function handleChangePassword() {
savingPassword = true;
passwordError = null;
const body = me?.has_password
? { current_password: currentPassword, new_password: newPassword }
: { new_password: newPassword, confirm_password: confirmPassword };
const result = await changePassword(body);
if (result.ok) {
currentPassword = '';
newPassword = '';
confirmPassword = '';
const wasPasswordSet = !!me?.has_password;
if (me) me = { ...me, has_password: true };
toast.success(wasPasswordSet ? 'Password updated.' : 'Password added.');
passwordSaved = true;
if (passwordSavedTimer) clearTimeout(passwordSavedTimer);
passwordSavedTimer = setTimeout(() => (passwordSaved = false), 1500);
} else {
passwordError = result.error;
}
savingPassword = false;
}
async function handleConnectGitHub() {
connectingGitHub = true;
const result = await getProviderConnectURL('github');
if (result.ok) {
window.location.href = result.data.auth_url;
} else {
toast.error(result.error);
connectingGitHub = false;
}
}
async function handleDisconnectGitHub() {
disconnectingGitHub = true;
disconnectError = null;
const result = await disconnectProvider('github');
if (result.ok) {
me = { ...me!, providers: me!.providers.filter((p) => p !== 'github') };
showDisconnectConfirm = false;
toast.success('GitHub disconnected.');
} else {
disconnectError = result.error;
}
disconnectingGitHub = false;
}
async function handleDeleteAccount() {
deleting = true;
deleteError = null;
const result = await deleteAccount(deleteConfirmation);
if (result.ok) {
auth.logout();
} else {
deleteError = result.error;
deleting = false;
}
}
onMount(async () => {
await fetchMe();
// Read OAuth callback params and clean URL immediately,
// regardless of whether fetchMe succeeds.
const connected = $page.url.searchParams.get('connected');
const connectErr = $page.url.searchParams.get('connect_error');
if (connected || connectErr) {
goto('/dashboard/settings', { replaceState: true });
}
if (connected) {
if (me) me = { ...me, providers: [...new Set([...me.providers, connected])] };
toast.success(`${connected.charAt(0).toUpperCase() + connected.slice(1)} connected successfully.`);
} else if (connectErr) {
toast.error(connectErrors[connectErr] ?? 'Failed to connect account.');
}
});
</script>
<svelte:head>
<title>Wrenn — Settings</title>
</svelte:head>
<div class="flex h-screen overflow-hidden">
<Sidebar bind:collapsed />
<div class="flex flex-1 flex-col overflow-hidden">
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
<!-- Header -->
<div class="px-7 pt-8">
<div>
<h1 class="font-serif text-page text-[var(--color-text-bright)]">Settings</h1>
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
Manage your account details and security.
</p>
</div>
<div class="mt-6 border-b border-[var(--color-border)]"></div>
</div>
<!-- Content -->
<div class="p-8">
{#if loadError}
<div class="rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]" style="animation: fadeUp 0.35s ease both">
{loadError}
</div>
{:else if me}
<div class="mx-auto max-w-[560px] space-y-8">
<!-- ── Profile ── -->
<section style="animation: fadeUp 0.35s ease both">
<div class="flex items-center gap-4">
<div class="avatar-ring flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
<span class="font-serif text-heading leading-none text-[var(--color-text-bright)]">{initials}</span>
</div>
<div>
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Profile</h2>
<p class="mt-0.5 text-ui text-[var(--color-text-tertiary)]">How you appear across Wrenn.</p>
</div>
</div>
<div class="mt-6 space-y-4">
<div>
<label
for="display-name"
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
>
Display name
</label>
<input
id="display-name"
type="text"
bind:value={editName}
disabled={savingName}
placeholder="Your name"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
/>
</div>
<div>
<span class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">
Email
</span>
<div class="rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-2 font-mono text-ui text-[var(--color-text-secondary)]">
{me.email}
</div>
</div>
{#if nameError}
<p class="text-ui text-[var(--color-red)]">{nameError}</p>
{/if}
<div class="flex justify-end">
<button
onclick={handleSaveName}
disabled={savingName || nameSaved || !editName.trim() || editName.trim() === me.name}
class="flex items-center gap-2 rounded-[var(--radius-button)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:-translate-y-px active:translate-y-0 disabled:hover:translate-y-0 {nameSaved ? 'bg-[var(--color-accent-bright)]' : 'bg-[var(--color-accent)] hover:brightness-115 disabled:opacity-50'}"
>
{#if savingName}
<svg class="animate-spin" width="13" height="13" 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>
Saving…
{:else if nameSaved}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-draw"><polyline points="20 6 9 17 4 12" /></svg>
Saved
{:else}
Save
{/if}
</button>
</div>
</div>
</section>
<div class="border-t border-[var(--color-border)]"></div>
<!-- ── Security ── -->
<section style="animation: fadeUp 0.35s ease both; animation-delay: 60ms">
<div class="flex items-start gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-tertiary)]"><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
</div>
<div>
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">
{me.has_password ? 'Change password' : 'Add a password'}
</h2>
<p class="mt-0.5 text-ui text-[var(--color-text-tertiary)]">
{me.has_password
? 'Use a strong, unique password you don\'t use elsewhere.'
: 'Set a password so you can sign in with your email.'}
</p>
</div>
</div>
<div class="mt-5 space-y-4">
{#if me.has_password}
<div>
<label
for="current-password"
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
>
Current password
</label>
<input
id="current-password"
type="password"
bind:value={currentPassword}
disabled={savingPassword}
autocomplete="current-password"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
/>
</div>
{/if}
<div>
<label
for="new-password"
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
>
New password
</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
disabled={savingPassword}
autocomplete="new-password"
placeholder="Min. 8 characters"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
/>
</div>
{#if !me.has_password}
<div>
<label
for="confirm-password"
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
>
Confirm password
</label>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
disabled={savingPassword}
autocomplete="new-password"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
/>
</div>
{/if}
{#if passwordError}
<p class="text-ui text-[var(--color-red)]">{passwordError}</p>
{/if}
<div class="flex items-center justify-between">
{#if me.has_password}
<button
type="button"
onclick={handleSendPasswordReset}
disabled={sendingReset}
class="text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)] disabled:opacity-50"
>
{sendingReset ? 'Sending…' : 'Forgot password?'}
</button>
{:else}
<span></span>
{/if}
<button
onclick={handleChangePassword}
disabled={savingPassword || passwordSaved || !newPassword || (me.has_password && !currentPassword) || (!me.has_password && !confirmPassword)}
class="flex items-center gap-2 rounded-[var(--radius-button)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:-translate-y-px active:translate-y-0 disabled:hover:translate-y-0 {passwordSaved ? 'bg-[var(--color-accent-bright)]' : 'bg-[var(--color-accent)] hover:brightness-115 disabled:opacity-50'}"
>
{#if savingPassword}
<svg class="animate-spin" width="13" height="13" 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>
Saving…
{:else if passwordSaved}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-draw"><polyline points="20 6 9 17 4 12" /></svg>
Saved
{:else}
{me.has_password ? 'Update password' : 'Set password'}
{/if}
</button>
</div>
</div>
</section>
<div class="border-t border-[var(--color-border)]"></div>
<!-- ── Connected Accounts ── -->
<section style="animation: fadeUp 0.35s ease both; animation-delay: 120ms">
<div class="flex items-start gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-tertiary)]"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
</div>
<div>
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Connected accounts</h2>
<p class="mt-0.5 text-ui text-[var(--color-text-tertiary)]">
Sign in with a linked account instead of your password.
</p>
</div>
</div>
<div class="mt-5">
<!-- GitHub row -->
<div class="flex items-center justify-between rounded-[var(--radius-card)] border px-4 py-3 transition-colors duration-200 {me.providers.includes('github') ? 'border-[var(--color-accent)]/30 bg-[var(--color-accent-glow)]' : 'border-[var(--color-border)] bg-[var(--color-bg-1)]'}">
<div class="flex items-center gap-3">
<!-- GitHub icon -->
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" class="{me.providers.includes('github') ? 'text-[var(--color-text-bright)]' : 'text-[var(--color-text-secondary)]'}">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
<div>
<div class="text-ui font-medium text-[var(--color-text-primary)]">GitHub</div>
{#if me.providers.includes('github')}
<div class="flex items-center gap-1 text-meta text-[var(--color-accent)]">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-draw"><polyline points="20 6 9 17 4 12" /></svg>
Connected
</div>
{:else}
<div class="text-meta text-[var(--color-text-muted)]">Not connected</div>
{/if}
</div>
</div>
{#if me.providers.includes('github')}
<button
onclick={() => { showDisconnectConfirm = true; disconnectError = null; }}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-red)]/50 hover:text-[var(--color-red)]"
>
Disconnect
</button>
{:else}
<button
onclick={handleConnectGitHub}
disabled={connectingGitHub}
class="flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
{#if connectingGitHub}
<svg class="animate-spin" width="13" height="13" 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>
{/if}
Connect
</button>
{/if}
</div>
</div>
</section>
<div class="border-t border-[var(--color-border)]"></div>
<!-- ── Danger Zone ── -->
<section style="animation: fadeUp 0.35s ease both; animation-delay: 180ms">
<h2 class="font-serif text-heading text-[var(--color-red)]">Danger zone</h2>
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
Deleting your account is irreversible.
</p>
<div class="mt-5 rounded-[var(--radius-card)] border border-[var(--color-red)]/25 border-l-[2px] border-l-[var(--color-red)]/40 bg-[var(--color-red)]/[0.03] px-4 py-4">
<div class="flex items-start justify-between gap-4">
<div>
<div class="text-ui font-medium text-[var(--color-text-primary)]">Delete account</div>
<div class="mt-0.5 text-meta text-[var(--color-text-muted)]">
Your account will be deactivated immediately and permanently removed after 15 days.
</div>
</div>
<button
onclick={() => { showDeleteConfirm = true; deleteConfirmation = ''; deleteError = null; }}
class="shrink-0 rounded-[var(--radius-button)] border border-[var(--color-red)]/30 px-3 py-1.5 text-ui text-[var(--color-red)] transition-colors duration-150 hover:bg-[var(--color-red)]/10"
>
Delete account
</button>
</div>
</div>
</section>
</div>
{:else}
<!-- Loading skeleton -->
<div class="mx-auto max-w-[560px] space-y-6">
<div class="flex items-center gap-4" style="animation: fadeUp 0.35s ease both">
<div class="h-14 w-14 shrink-0 animate-pulse rounded-full bg-[var(--color-bg-3)]"></div>
<div class="flex-1 space-y-2">
<div class="h-4 w-24 animate-pulse rounded bg-[var(--color-bg-3)]"></div>
<div class="h-3 w-40 animate-pulse rounded bg-[var(--color-bg-2)]"></div>
</div>
</div>
{#each [140, 180, 100] as h, i}
<div style="animation: fadeUp 0.35s ease both; animation-delay: {(i + 1) * 60}ms">
<div class="animate-pulse rounded-[var(--radius-card)] bg-[var(--color-bg-2)]" style="height: {h}px"></div>
</div>
{/each}
</div>
{/if}
</div>
</main>
<footer class="h-px shrink-0 bg-[var(--color-border)]"></footer>
</div>
</div>
<!-- Disconnect GitHub dialog -->
{#if showDisconnectConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60 backdrop-fade"
onclick={() => { if (!disconnectingGitHub) showDisconnectConfirm = false; }}
onkeydown={(e) => { if (e.key === 'Escape' && !disconnectingGitHub) showDisconnectConfirm = false; }}
></div>
<div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
>
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Disconnect GitHub</h2>
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
You won't be able to sign in with GitHub. You can reconnect it later.
</p>
{#if disconnectError}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{disconnectError}
</div>
{/if}
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => showDisconnectConfirm = false}
disabled={disconnectingGitHub}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleDisconnectGitHub}
disabled={disconnectingGitHub}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if disconnectingGitHub}
<svg class="animate-spin" width="13" height="13" 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>
{/if}
Disconnect
</button>
</div>
</div>
</div>
{/if}
<!-- Delete account dialog -->
{#if showDeleteConfirm}
<div class="fixed inset-0 z-50 flex items-center justify-center">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 bg-black/60 backdrop-fade"
onclick={() => { if (!deleting) showDeleteConfirm = false; }}
onkeydown={(e) => { if (e.key === 'Escape' && !deleting) showDeleteConfirm = false; }}
></div>
<div
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
>
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Delete account</h2>
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
Your account will be deactivated immediately and permanently deleted after 15 days. This cannot be undone.
</p>
{#if deleteError}
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
{deleteError}
</div>
{/if}
<div class="mt-5">
<label
for="delete-confirm"
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
>
Type your email to confirm
</label>
<input
id="delete-confirm"
type="email"
bind:value={deleteConfirmation}
disabled={deleting}
placeholder={me?.email ?? ''}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-red)] focus:shadow-[0_0_0_2px_rgba(207,129,114,0.1)] disabled:opacity-60"
/>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
onclick={() => showDeleteConfirm = false}
disabled={deleting}
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
>
Cancel
</button>
<button
onclick={handleDeleteAccount}
disabled={deleting || deleteConfirmation !== me?.email}
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if deleting}
<svg class="animate-spin" width="13" height="13" 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>
{/if}
Delete account
</button>
</div>
</div>
</div>
{/if}
<style>
/* ── Checkmark draw animation (mirrors CopyButton pattern) ── */
.check-draw {
animation: checkScale 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
}
:global(.check-draw polyline) {
stroke-dasharray: 24;
stroke-dashoffset: 24;
animation: checkStroke 0.3s cubic-bezier(0.25, 1, 0.5, 1) 0.05s forwards;
}
@keyframes checkScale {
0% { transform: scale(0.6); opacity: 0; }
50% { opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes checkStroke {
to { stroke-dashoffset: 0; }
}
/* ── Avatar hover ring ── */
.avatar-ring {
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.avatar-ring:hover {
border-color: var(--color-accent-mid);
box-shadow: 0 0 0 3px var(--color-accent-glow-mid);
}
/* ── Dialog backdrop fade ── */
.backdrop-fade {
animation: backdropIn 0.2s ease both;
}
@keyframes backdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* ── Respect reduced motion ── */
@media (prefers-reduced-motion: reduce) {
.check-draw,
.avatar-ring,
.backdrop-fade {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
:global(.check-draw polyline) {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
</style>

View File

@ -0,0 +1,99 @@
<script lang="ts">
import { requestPasswordReset } from '$lib/api/me';
let email = $state('');
let loading = $state(false);
let submitted = $state(false);
let error = $state('');
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
loading = true;
await requestPasswordReset(email.trim().toLowerCase());
// Always show success to avoid leaking account existence
submitted = true;
loading = false;
}
</script>
<svelte:head>
<title>Wrenn — Reset password</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-8 w-8 rounded-[var(--radius-logo)]" />
<span class="font-brand text-[1.5rem] text-[var(--color-text-bright)]">Wrenn</span>
</div>
{#if submitted}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
<h1 class="font-serif text-heading text-[var(--color-text-bright)]">Check your email</h1>
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
If an account exists for <span class="font-mono text-[var(--color-text-primary)]">{email}</span>, you'll receive a reset link shortly. The link expires in 15 minutes.
</p>
<a
href="/login"
class="mt-6 block text-center text-ui text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
>
Back to sign in
</a>
</div>
{:else}
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
<h1 class="font-serif text-heading text-[var(--color-text-bright)]">Reset your password</h1>
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
Enter your email and we'll send you a reset link.
</p>
{#if error}
<p class="mt-4 text-ui text-[var(--color-red)]">{error}</p>
{/if}
<form onsubmit={handleSubmit} class="mt-5 space-y-4">
<div>
<label
for="email"
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
>
Email
</label>
<input
id="email"
type="email"
bind:value={email}
required
disabled={loading}
placeholder="you@example.com"
autocomplete="email"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
</div>
<button
type="submit"
disabled={loading || !email.trim()}
class="flex w-full items-center justify-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] py-2.5 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#if loading}
<svg class="animate-spin" width="14" height="14" 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>
Sending…
{:else}
Send reset link
{/if}
</button>
</form>
<a
href="/login"
class="mt-5 block text-center text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
>
Back to sign in
</a>
</div>
{/if}
</div>
</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',
@ -31,6 +33,11 @@
// Read OAuth error forwarded from /auth/github/callback
onMount(() => {
if (auth.isAuthenticated) {
goto('/dashboard');
return;
}
const urlErr = $page.url.searchParams.get('error');
if (urlErr) {
const decoded = decodeURIComponent(urlErr);
@ -90,6 +97,8 @@
mode = mode === 'signin' ? 'signup' : 'signin';
error = '';
name = '';
confirmPassword = '';
signupDone = false;
}
async function handleSubmit(e: Event) {
@ -97,11 +106,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 +222,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>
<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>
{#if mode === 'signin'}
<div class="flex justify-end">
<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"
class="text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
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}
>
Forgot password?
{#if showPassword}
<IconEyeOff size={14} />
{:else}
<IconEye size={14} />
{/if}
</button>
</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 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}
</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>
{#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>
{/if}
</div>
</div>
</div>

View File

@ -0,0 +1,138 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { confirmPasswordReset } from '$lib/api/me';
import { IconLock } from '$lib/components/icons';
let token = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let loading = $state(false);
let error = $state('');
let done = $state(false);
onMount(() => {
token = $page.url.searchParams.get('token') ?? '';
if (!token) {
goto('/forgot-password');
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
error = '';
if (newPassword !== confirmPassword) {
error = 'Passwords do not match.';
return;
}
if (newPassword.length < 8) {
error = 'Password must be at least 8 characters.';
return;
}
loading = true;
const result = await confirmPasswordReset(token, newPassword);
if (result.ok) {
done = true;
} else {
error = result.error;
}
loading = false;
}
</script>
<svelte:head>
<title>Wrenn — Set new password</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 done}
<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">
<h1 class="font-serif text-display text-[var(--color-text-bright)]">All set</h1>
<p class="mt-1 text-ui text-[var(--color-text-secondary)]">
Your password has been updated. Sign in to continue.
</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"
>
Sign in
</a>
</div>
{:else}
<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)]">Set new password</h1>
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Must be at least 8 characters.</p>
{#if error}
<p class="mt-4 text-ui text-[var(--color-red)]">{error}</p>
{/if}
<form onsubmit={handleSubmit} class="mt-6 space-y-3">
<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
id="new-password"
type="password"
bind:value={newPassword}
required
disabled={loading}
placeholder="New 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 placeholder:text-[var(--color-text-muted)] transition-all duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
</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>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
required
disabled={loading}
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 placeholder:text-[var(--color-text-muted)] transition-all duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/>
</div>
<button
type="submit"
disabled={loading || !newPassword || !confirmPassword}
class="!mt-5 w-full 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 disabled:opacity-50 disabled:hover:translate-y-0"
>
{#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>
Updating…
</span>
{:else}
Set password
{/if}
</button>
</form>
</div>
{/if}
<a
href="/login"
class="mt-5 block text-center text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
>
Back to sign in
</a>
</div>
</div>

View File

@ -63,7 +63,7 @@ func apiKeyWithCreatorToResponse(k db.ListAPIKeysByTeamWithCreatorRow) apiKeyRes
Name: k.Name,
KeyPrefix: k.KeyPrefix,
CreatedBy: id.FormatUserID(k.CreatedBy),
CreatorEmail: k.CreatorEmail,
CreatorEmail: k.CreatorEmail.String,
}
if k.CreatedAt.Valid {
resp.CreatedAt = k.CreatedAt.Time.Format(time.RFC3339)

View File

@ -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[:])
}

562
internal/api/handlers_me.go Normal file
View File

@ -0,0 +1,562 @@
package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"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"
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
"git.omukk.dev/wrenn/wrenn/pkg/db"
"git.omukk.dev/wrenn/wrenn/pkg/id"
"git.omukk.dev/wrenn/wrenn/pkg/service"
)
const (
passwordResetKeyPrefix = "wrenn:password_reset:"
passwordResetTTL = 15 * time.Minute
)
type meHandler struct {
db *db.Queries
pool *pgxpool.Pool
rdb *redis.Client
jwtSecret []byte
mailer email.Mailer
oauthRegistry *oauth.Registry
redirectURL string
teamSvc *service.TeamService
}
func newMeHandler(
db *db.Queries,
pool *pgxpool.Pool,
rdb *redis.Client,
jwtSecret []byte,
mailer email.Mailer,
registry *oauth.Registry,
redirectURL string,
teamSvc *service.TeamService,
) *meHandler {
return &meHandler{
db: db,
pool: pool,
rdb: rdb,
jwtSecret: jwtSecret,
mailer: mailer,
oauthRegistry: registry,
redirectURL: strings.TrimRight(redirectURL, "/"),
teamSvc: teamSvc,
}
}
type meResponse struct {
Name string `json:"name"`
Email string `json:"email"`
HasPassword bool `json:"has_password"`
Providers []string `json:"providers"`
}
type updateNameRequest struct {
Name string `json:"name"`
}
type changePasswordRequest struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
ConfirmPassword string `json:"confirm_password"`
}
type requestPasswordResetRequest struct {
Email string `json:"email"`
}
type confirmPasswordResetRequest struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
type deleteAccountRequest struct {
Confirmation string `json:"confirmation"`
}
// GetMe handles GET /v1/me.
func (h *meHandler) GetMe(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
ctx := r.Context()
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
return
}
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
return
}
providerNames := make([]string, 0, len(providers))
for _, p := range providers {
providerNames = append(providerNames, p.Provider)
}
writeJSON(w, http.StatusOK, meResponse{
Name: user.Name,
Email: user.Email,
HasPassword: user.PasswordHash.Valid,
Providers: providerNames,
})
}
// UpdateName handles PATCH /v1/me — updates the user's name and re-issues a JWT.
func (h *meHandler) UpdateName(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
ctx := r.Context()
var req updateNameRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" || len(req.Name) > 100 {
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
return
}
if err := h.db.UpdateUserName(ctx, db.UpdateUserNameParams{
ID: ac.UserID,
Name: req.Name,
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update name")
return
}
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
return
}
team, role, err := loginTeam(ctx, h.db, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get team")
return
}
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, team.ID, user.Email, req.Name, role, user.IsAdmin)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
return
}
writeJSON(w, http.StatusOK, authResponse{
Token: token,
UserID: id.FormatUserID(ac.UserID),
TeamID: id.FormatTeamID(team.ID),
Email: user.Email,
Name: req.Name,
})
}
// ChangePassword handles POST /v1/me/password.
// For users with a password: requires current_password + new_password.
// For OAuth-only users: requires new_password + confirm_password.
func (h *meHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
ctx := r.Context()
var req changePasswordRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
return
}
if user.PasswordHash.Valid {
// Changing existing password — verify current.
if req.CurrentPassword == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "current_password is required")
return
}
if err := auth.CheckPassword(user.PasswordHash.String, req.CurrentPassword); err != nil {
writeError(w, http.StatusUnauthorized, "wrong_password", "current password is incorrect")
return
}
} else {
// OAuth user adding a password — confirm must match.
if req.ConfirmPassword == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "confirm_password is required")
return
}
if req.NewPassword != req.ConfirmPassword {
writeError(w, http.StatusBadRequest, "invalid_request", "passwords do not match")
return
}
}
if len(req.NewPassword) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
return
}
if err := h.db.UpdateUserPassword(ctx, db.UpdateUserPasswordParams{
ID: ac.UserID,
PasswordHash: pgtype.Text{String: hash, Valid: true},
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
return
}
isAdding := !user.PasswordHash.Valid
go func() {
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
subject, message := "Your Wrenn password was changed", "Your account password was successfully updated. If you did not make this change, reset your password immediately."
if isAdding {
subject = "Password added to your Wrenn account"
message = "A password has been added to your Wrenn account. You can now sign in with your email and password in addition to any connected OAuth providers."
}
if err := h.mailer.Send(sendCtx, user.Email, subject, email.EmailData{
RecipientName: user.Name,
Message: message,
Closing: "If you didn't make this change, contact support immediately.",
}); err != nil {
slog.Warn("change password: failed to send notification", "email", user.Email, "error", err)
}
}()
w.WriteHeader(http.StatusNoContent)
}
// RequestPasswordReset handles POST /v1/me/password/reset (unauthenticated).
// Always returns 200 to avoid leaking account existence.
func (h *meHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
var req requestPasswordResetRequest
if err := decodeJSON(r, &req); err != nil {
w.WriteHeader(http.StatusNoContent)
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" {
w.WriteHeader(http.StatusNoContent)
return
}
ctx := r.Context()
user, err := h.db.GetUserByEmail(ctx, req.Email)
if err != nil {
// Don't leak whether the email exists.
w.WriteHeader(http.StatusNoContent)
return
}
if user.Status != "active" {
w.WriteHeader(http.StatusNoContent)
return
}
rawToken := generateResetToken()
tokenHash := hashResetToken(rawToken)
redisKey := passwordResetKeyPrefix + tokenHash
if err := h.rdb.Set(ctx, redisKey, id.FormatUserID(user.ID), passwordResetTTL).Err(); err != nil {
slog.Error("password reset: failed to store token in redis", "error", err)
w.WriteHeader(http.StatusNoContent)
return
}
resetURL := h.redirectURL + "/reset-password?token=" + rawToken
go func() {
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := h.mailer.Send(sendCtx, user.Email, "Reset your Wrenn password", email.EmailData{
RecipientName: user.Name,
Message: "We received a request to reset your password. Click the button below to set a new password. This link expires in 15 minutes.",
Button: &email.Button{Text: "Reset Password", URL: resetURL},
Closing: "If you didn't request a password reset, you can safely ignore this email.",
}); err != nil {
slog.Error("password reset: failed to send email", "email", user.Email, "error", err)
}
}()
w.WriteHeader(http.StatusNoContent)
}
// ConfirmPasswordReset handles POST /v1/me/password/reset/confirm (unauthenticated).
func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
var req confirmPasswordResetRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
return
}
if len(req.NewPassword) < 8 {
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
return
}
ctx := r.Context()
tokenHash := hashResetToken(req.Token)
redisKey := passwordResetKeyPrefix + tokenHash
// GetDel atomically retrieves and removes the token in a single round-trip,
// preventing concurrent requests from both consuming the same token.
userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result()
if errors.Is(err, redis.Nil) {
writeError(w, http.StatusBadRequest, "invalid_token", "reset token 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
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
return
}
if err := h.db.UpdateUserPassword(ctx, db.UpdateUserPasswordParams{
ID: userID,
PasswordHash: pgtype.Text{String: hash, Valid: true},
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
return
}
go func() {
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := h.mailer.Send(sendCtx, user.Email, "Your Wrenn password was reset", email.EmailData{
RecipientName: user.Name,
Message: "Your password has been successfully reset. You can now sign in with your new password.",
Closing: "If you didn't request this change, contact support immediately.",
}); err != nil {
slog.Warn("confirm password reset: failed to send notification", "email", user.Email, "error", err)
}
}()
w.WriteHeader(http.StatusNoContent)
}
// ConnectProvider handles GET /v1/me/providers/{provider}/connect.
// Sets OAuth state + link cookies and returns the provider auth URL.
func (h *meHandler) ConnectProvider(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
provider := chi.URLParam(r, "provider")
p, ok := h.oauthRegistry.Get(provider)
if !ok {
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
return
}
state, err := generateState()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state")
return
}
mac := computeHMAC(h.jwtSecret, state)
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: state + ":" + mac,
Path: "/",
MaxAge: 600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure(r),
})
userIDStr := id.FormatUserID(ac.UserID)
linkMac := computeHMAC(h.jwtSecret, userIDStr)
http.SetCookie(w, &http.Cookie{
Name: "oauth_link_user_id",
Value: userIDStr + ":" + linkMac,
Path: "/",
MaxAge: 600,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure(r),
})
writeJSON(w, http.StatusOK, map[string]string{"auth_url": p.AuthCodeURL(state)})
}
// DisconnectProvider handles DELETE /v1/me/providers/{provider}.
func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
provider := chi.URLParam(r, "provider")
ctx := r.Context()
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
return
}
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
return
}
// Ensure the user will still have at least one login method after disconnecting.
if !user.PasswordHash.Valid && len(providers) <= 1 {
writeError(w, http.StatusBadRequest, "last_login_method", "cannot disconnect your only login method — add a password first")
return
}
// Check the provider is actually linked to this user.
found := false
for _, p := range providers {
if p.Provider == provider {
found = true
break
}
}
if !found {
writeError(w, http.StatusNotFound, "not_found", "provider not connected")
return
}
if err := h.db.DeleteOAuthProvider(ctx, db.DeleteOAuthProviderParams{
UserID: ac.UserID,
Provider: provider,
}); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to disconnect provider")
return
}
w.WriteHeader(http.StatusNoContent)
}
// DeleteAccount handles DELETE /v1/me — soft-deletes the user's account.
func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
ac := auth.MustFromContext(r.Context())
ctx := r.Context()
var req deleteAccountRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
return
}
user, err := h.db.GetUserByID(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
return
}
if !strings.EqualFold(strings.TrimSpace(req.Confirmation), user.Email) {
writeError(w, http.StatusBadRequest, "invalid_request", "confirmation does not match your email address")
return
}
teamsBlocking, err := h.db.CountUserOwnedTeamsWithOtherMembers(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to check team ownership")
return
}
if teamsBlocking > 0 {
writeError(w, http.StatusConflict, "owns_team_with_members",
fmt.Sprintf("you own %d team(s) with other members — transfer ownership or remove members before deleting your account", teamsBlocking))
return
}
// Delete all teams the user solely owns (no other members).
soleTeams, err := h.db.ListSoleOwnedTeams(ctx, ac.UserID)
if err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to list owned teams")
return
}
for _, teamID := range soleTeams {
if err := h.teamSvc.DeleteTeamInternal(ctx, teamID); err != nil {
slog.Warn("account delete: failed to delete sole-owned team",
"team_id", id.FormatTeamID(teamID), "error", err)
}
}
if err := h.db.SoftDeleteUser(ctx, ac.UserID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account")
return
}
slog.Info("account soft-deleted", "user_id", id.FormatUserID(ac.UserID), "email", user.Email)
go func() {
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := h.mailer.Send(sendCtx, user.Email, "Your Wrenn account has been deleted", email.EmailData{
RecipientName: user.Name,
Message: "Your Wrenn account has been deactivated and is scheduled for permanent deletion in 15 days. If this was a mistake, contact support before then to recover your account.",
Closing: "Thank you for using Wrenn.",
}); err != nil {
slog.Warn("delete account: failed to send notification", "email", user.Email, "error", err)
}
}()
w.WriteHeader(http.StatusNoContent)
}
// --- helpers ---
func generateResetToken() 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 hashResetToken(raw string) string {
h := sha256.Sum256([]byte(raw))
return hex.EncodeToString(h[:])
}

View File

@ -137,6 +137,73 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(strings.ToLower(profile.Email))
// Check for a link operation initiated from the settings page.
if linkCookie, err := r.Cookie("oauth_link_user_id"); err == nil && linkCookie.Value != "" {
// Clear the link cookie immediately.
http.SetCookie(w, &http.Cookie{
Name: "oauth_link_user_id",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure(r),
})
settingsBase := h.redirectURL + "/dashboard/settings"
// Verify the HMAC to prevent cookie forgery.
linkParts := strings.SplitN(linkCookie.Value, ":", 2)
if len(linkParts) != 2 || !hmac.Equal([]byte(computeHMAC(h.jwtSecret, linkParts[0])), []byte(linkParts[1])) {
slog.Warn("oauth link: invalid or tampered link cookie")
http.Redirect(w, r, settingsBase+"?connect_error=invalid_state", http.StatusFound)
return
}
userID, parseErr := id.ParseUserID(linkParts[0])
if parseErr != nil {
slog.Error("oauth link: invalid user ID in cookie", "error", parseErr)
http.Redirect(w, r, settingsBase+"?connect_error=invalid_state", http.StatusFound)
return
}
// Ensure the GitHub account isn't already linked to a different user.
existing, lookupErr := h.db.GetOAuthProvider(ctx, db.GetOAuthProviderParams{
Provider: provider,
ProviderID: profile.ProviderID,
})
if lookupErr == nil && existing.UserID != userID {
slog.Warn("oauth link: provider already linked to another account", "provider", provider)
http.Redirect(w, r, settingsBase+"?connect_error=already_linked", http.StatusFound)
return
}
if lookupErr == nil && existing.UserID == userID {
// Already linked to this user — treat as success.
http.Redirect(w, r, settingsBase+"?connected="+provider, http.StatusFound)
return
}
if !errors.Is(lookupErr, pgx.ErrNoRows) {
slog.Error("oauth link: db lookup failed", "error", lookupErr)
http.Redirect(w, r, settingsBase+"?connect_error=db_error", http.StatusFound)
return
}
if insertErr := h.db.InsertOAuthProvider(ctx, db.InsertOAuthProviderParams{
Provider: provider,
ProviderID: profile.ProviderID,
UserID: userID,
Email: email,
}); insertErr != nil {
slog.Error("oauth link: failed to insert provider", "error", insertErr)
http.Redirect(w, r, settingsBase+"?connect_error=db_error", http.StatusFound)
return
}
slog.Info("oauth link: provider linked", "provider", provider, "user_id", id.FormatUserID(userID))
http.Redirect(w, r, settingsBase+"?connected="+provider, http.StatusFound)
return
}
// Check if this OAuth identity already exists.
existing, err := h.db.GetOAuthProvider(ctx, db.GetOAuthProviderParams{
Provider: provider,
@ -150,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
}
@ -177,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
@ -306,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
}

View File

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

View File

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

View File

@ -50,7 +50,7 @@ func requireJWT(secret []byte, queries *db.Queries) func(http.Handler) http.Hand
writeError(w, http.StatusUnauthorized, "unauthorized", "user not found")
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
}

View File

@ -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:
@ -175,6 +211,252 @@ paths:
"302":
description: Redirect to frontend with token or error
/v1/me:
get:
summary: Get current user profile
operationId: getMe
tags: [account]
security:
- bearerAuth: []
responses:
"200":
description: User profile
content:
application/json:
schema:
$ref: "#/components/schemas/MeResponse"
patch:
summary: Update display name
operationId: updateName
tags: [account]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
minLength: 1
maxLength: 100
responses:
"200":
description: Name updated, new JWT issued
content:
application/json:
schema:
$ref: "#/components/schemas/AuthResponse"
"400":
description: Invalid name
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
delete:
summary: Delete current account
operationId: deleteAccount
tags: [account]
security:
- bearerAuth: []
description: |
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:
required: true
content:
application/json:
schema:
type: object
required: [confirmation]
properties:
confirmation:
type: string
description: Must match the user's email address (case-insensitive)
responses:
"204":
description: Account scheduled for deletion
"400":
description: Confirmation does not match email
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"409":
description: User owns teams with other members
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/me/password:
post:
summary: Change or add password
operationId: changePassword
tags: [account]
security:
- bearerAuth: []
description: |
For users with an existing password: requires `current_password` and `new_password`.
For OAuth-only users adding a password: requires `new_password` and `confirm_password`.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ChangePasswordRequest"
responses:
"204":
description: Password updated
"400":
description: Invalid request (short password, mismatch, etc.)
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"401":
description: Current password is incorrect
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/me/password/reset:
post:
summary: Request a password reset email
operationId: requestPasswordReset
tags: [account]
description: |
Sends a password reset link to the given email. Always returns 200
regardless of whether the email exists, to prevent account enumeration.
The reset token expires in 15 minutes.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email:
type: string
format: email
responses:
"204":
description: Request accepted (email sent if account exists)
/v1/me/password/reset/confirm:
post:
summary: Confirm password reset
operationId: confirmPasswordReset
tags: [account]
description: |
Consumes a password reset token and sets a new password. The token is
single-use and expires after 15 minutes.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [token, new_password]
properties:
token:
type: string
description: Raw reset token from the email link
new_password:
type: string
minLength: 8
responses:
"204":
description: Password reset successful
"400":
description: Invalid or expired token, or password too short
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/me/providers/{provider}/connect:
parameters:
- name: provider
in: path
required: true
schema:
type: string
enum: [github]
description: OAuth provider name
get:
summary: Initiate OAuth provider link
operationId: connectProvider
tags: [account]
security:
- bearerAuth: []
description: |
Sets OAuth state and link cookies, then returns the provider's
authorization URL. The frontend navigates to this URL to start the
OAuth flow. On callback, the provider is linked to the current account
(not a new registration).
responses:
"200":
description: Authorization URL
content:
application/json:
schema:
type: object
properties:
auth_url:
type: string
format: uri
"404":
description: Provider not found or not configured
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/me/providers/{provider}:
parameters:
- name: provider
in: path
required: true
schema:
type: string
enum: [github]
description: OAuth provider name
delete:
summary: Disconnect an OAuth provider
operationId: disconnectProvider
tags: [account]
security:
- bearerAuth: []
description: |
Unlinks the OAuth provider from the current account. Blocked if this
is the user's only login method (no password and no other providers).
responses:
"204":
description: Provider disconnected
"400":
description: Cannot disconnect last login method
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"404":
description: Provider not connected
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
/v1/api-keys:
post:
summary: Create an API key
@ -2077,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:
@ -2780,6 +3069,37 @@ components:
nullable: true
description: Webhook secret. Only returned on creation, never again.
MeResponse:
type: object
properties:
name:
type: string
email:
type: string
format: email
has_password:
type: boolean
description: Whether the user has a password set (false for OAuth-only accounts)
providers:
type: array
items:
type: string
description: List of linked OAuth provider names (e.g. ["github"])
ChangePasswordRequest:
type: object
required: [new_password]
properties:
current_password:
type: string
description: Required when changing an existing password
new_password:
type: string
minLength: 8
confirm_password:
type: string
description: Required when adding a password to an OAuth-only account (must match new_password)
Error:
type: object
properties:

View File

@ -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)
@ -84,6 +84,7 @@ func New(
ptyH := newPtyHandler(queries, pool)
processH := newProcessHandler(queries, pool)
adminCapsules := newAdminCapsuleHandler(sandboxSvc, queries, pool, al)
meH := newMeHandler(queries, pgPool, rdb, jwtSecret, mailer, oauthRegistry, oauthRedirectURL, teamSvc)
// OpenAPI spec and docs.
r.Get("/openapi.yaml", serveOpenAPI)
@ -92,9 +93,25 @@ 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)
// Unauthenticated: password reset request and confirmation.
r.Post("/v1/me/password/reset", meH.RequestPasswordReset)
r.Post("/v1/me/password/reset/confirm", meH.ConfirmPasswordReset)
// JWT-authenticated: self-service account management.
r.Route("/v1/me", func(r chi.Router) {
r.Use(requireJWT(jwtSecret, queries))
r.Get("/", meH.GetMe)
r.Patch("/", meH.UpdateName)
r.Post("/password", meH.ChangePassword)
r.Get("/providers/{provider}/connect", meH.ConnectProvider)
r.Delete("/providers/{provider}", meH.DisconnectProvider)
r.Delete("/", meH.DeleteAccount)
})
// JWT-authenticated: switch active team.
r.With(requireJWT(jwtSecret, queries)).Post("/v1/auth/switch-team", authH.SwitchTeam)

View File

@ -28,12 +28,21 @@
<tr>
<td align="center" style="padding: 40px 16px;">
<!-- Logo -->
<!-- Logo + Wordmark -->
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px;">
<tr>
<td align="left" style="padding-bottom: 24px;">
<td align="left" style="padding-bottom: 32px;">
<a href="https://wrenn.dev" style="text-decoration: none;">
<img src="https://wrenn.dev/logo.png" alt="Wrenn" width="36" height="36" style="display: block; border-radius: 6px;">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="vertical-align: middle;">
<img src="https://wrenn.dev/logo.png" alt="Wrenn" width="36" height="36" style="display: block; border-radius: 6px;">
</td>
<td style="vertical-align: middle; padding-left: 10px;">
<span style="font-family: 'Alice', Georgia, 'Times New Roman', serif; font-size: 22px; color: #1a1917; letter-spacing: 0.01em;">Wrenn</span>
</td>
</tr>
</table>
</a>
</td>
</tr>
@ -42,21 +51,21 @@
<!-- Card -->
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px; background-color: #ffffff; border: 1px solid #e5e4e0; border-radius: 8px;">
<tr>
<td style="padding: 40px 44px;">
<td style="padding: 44px 48px;">
<!-- Greeting -->
<p style="margin: 0 0 20px 0; font-size: 15px; line-height: 1.6; color: #3a3835;">
<p style="margin: 0 0 8px 0; font-size: 15px; line-height: 1.6; color: #3a3835;">
Hello{{if .RecipientName}} {{.RecipientName}}{{end}},
</p>
<!-- Message -->
<p style="margin: 0 0 24px 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
<p style="margin: 0 0 36px 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
{{.Message}}
</p>
<!-- Button -->
{{if .Button}}
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 28px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 36px 0;">
<tr>
<td align="center" style="background-color: #5e8c58; border-radius: 5px;">
<!--[if mso]>
@ -66,7 +75,7 @@
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a href="{{.Button.URL}}" target="_blank" style="display: inline-block; padding: 12px 28px; font-size: 14px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 5px; background-color: #5e8c58;">
<a href="{{.Button.URL}}" target="_blank" style="display: inline-block; padding: 12px 32px; font-size: 14px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 5px; background-color: #5e8c58;">
{{.Button.Text}}
</a>
<!--<![endif]-->
@ -74,7 +83,7 @@
</tr>
</table>
<p style="margin: 0 0 24px 0; font-size: 13px; line-height: 1.6; color: #9b9790;">
<p style="margin: 0 0 12px 0; font-size: 12px; line-height: 1.5; color: #b5b0a8;">
If the button doesn't work, copy and paste this URL into your browser:<br>
<a href="{{.Button.URL}}" style="color: #5e8c58; word-break: break-all;">{{.Button.URL}}</a>
</p>
@ -82,7 +91,7 @@
<!-- Closing -->
{{if .Closing}}
<p style="margin: 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
<p style="margin: {{if .Button}}20px{{else}}0{{end}} 0 0 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
{{.Closing}}
</p>
{{end}}
@ -94,7 +103,7 @@
<!-- Footer -->
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px;">
<tr>
<td style="padding: 24px 0; text-align: center;">
<td style="padding: 32px 0 16px 0; text-align: center;">
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #9b9790;">
This is a transactional email from <a href="https://wrenn.dev" style="color: #5e8c58; text-decoration: none;">Wrenn</a>.
</p>

View File

@ -188,6 +188,24 @@ func Run(opts ...Option) {
monitor := api.NewHostMonitor(queries, hostPool, al, 30*time.Second)
monitor.Start(ctx)
// Hard-delete accounts that have been soft-deleted for more than 15 days (runs every 24h).
go func() {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := queries.HardDeleteExpiredUsers(ctx); err != nil {
slog.Error("account cleanup: failed to hard-delete expired users", "error", err)
} else {
slog.Info("account cleanup: hard-deleted expired users")
}
}
}
}()
// Start metrics sampler (records per-team sandbox stats every 10s).
sampler := api.NewMetricsSampler(queries, 10*time.Second)
sampler.Start(ctx)

View File

@ -25,6 +25,15 @@ func (q *Queries) DeleteAPIKey(ctx context.Context, arg DeleteAPIKeyParams) erro
return err
}
const deleteAPIKeysByTeam = `-- name: DeleteAPIKeysByTeam :exec
DELETE FROM team_api_keys WHERE team_id = $1
`
func (q *Queries) DeleteAPIKeysByTeam(ctx context.Context, teamID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAPIKeysByTeam, teamID)
return err
}
const getAPIKeyByHash = `-- name: GetAPIKeyByHash :one
SELECT id, team_id, name, key_hash, key_prefix, created_by, created_at, last_used FROM team_api_keys WHERE key_hash = $1
`
@ -120,7 +129,7 @@ const listAPIKeysByTeamWithCreator = `-- name: ListAPIKeysByTeamWithCreator :man
SELECT k.id, k.team_id, k.name, k.key_hash, k.key_prefix, k.created_by, k.created_at, k.last_used,
u.email AS creator_email
FROM team_api_keys k
JOIN users u ON u.id = k.created_by
LEFT JOIN users u ON u.id = k.created_by
WHERE k.team_id = $1
ORDER BY k.created_at DESC
`
@ -134,7 +143,7 @@ type ListAPIKeysByTeamWithCreatorRow struct {
CreatedBy pgtype.UUID `json:"created_by"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
LastUsed pgtype.Timestamptz `json:"last_used"`
CreatorEmail string `json:"creator_email"`
CreatorEmail pgtype.Text `json:"creator_email"`
}
func (q *Queries) ListAPIKeysByTeamWithCreator(ctx context.Context, teamID pgtype.UUID) ([]ListAPIKeysByTeamWithCreatorRow, error) {

View File

@ -11,6 +11,15 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const deleteAllChannelsByTeam = `-- name: DeleteAllChannelsByTeam :exec
DELETE FROM channels WHERE team_id = $1
`
func (q *Queries) DeleteAllChannelsByTeam(ctx context.Context, teamID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAllChannelsByTeam, teamID)
return err
}
const deleteChannelByTeam = `-- name: DeleteChannelByTeam :exec
DELETE FROM channels WHERE id = $1 AND team_id = $2
`

View File

@ -11,6 +11,25 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const deleteMetricPointsByTeam = `-- name: DeleteMetricPointsByTeam :exec
DELETE FROM sandbox_metric_points
WHERE sandbox_id IN (SELECT id FROM sandboxes WHERE team_id = $1)
`
func (q *Queries) DeleteMetricPointsByTeam(ctx context.Context, teamID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteMetricPointsByTeam, teamID)
return err
}
const deleteMetricsSnapshotsByTeam = `-- name: DeleteMetricsSnapshotsByTeam :exec
DELETE FROM sandbox_metrics_snapshots WHERE team_id = $1
`
func (q *Queries) DeleteMetricsSnapshotsByTeam(ctx context.Context, teamID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteMetricsSnapshotsByTeam, teamID)
return err
}
const deleteSandboxMetricPoints = `-- name: DeleteSandboxMetricPoints :exec
DELETE FROM sandbox_metric_points
WHERE sandbox_id = $1

View File

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

View File

@ -11,6 +11,20 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const deleteOAuthProvider = `-- name: DeleteOAuthProvider :exec
DELETE FROM oauth_providers WHERE user_id = $1 AND provider = $2
`
type DeleteOAuthProviderParams struct {
UserID pgtype.UUID `json:"user_id"`
Provider string `json:"provider"`
}
func (q *Queries) DeleteOAuthProvider(ctx context.Context, arg DeleteOAuthProviderParams) error {
_, err := q.db.Exec(ctx, deleteOAuthProvider, arg.UserID, arg.Provider)
return err
}
const getOAuthProvider = `-- name: GetOAuthProvider :one
SELECT provider, provider_id, user_id, email, created_at FROM oauth_providers
WHERE provider = $1 AND provider_id = $2
@ -34,6 +48,36 @@ func (q *Queries) GetOAuthProvider(ctx context.Context, arg GetOAuthProviderPara
return i, err
}
const getOAuthProvidersByUserID = `-- name: GetOAuthProvidersByUserID :many
SELECT provider, provider_id, user_id, email, created_at FROM oauth_providers WHERE user_id = $1
`
func (q *Queries) GetOAuthProvidersByUserID(ctx context.Context, userID pgtype.UUID) ([]OauthProvider, error) {
rows, err := q.db.Query(ctx, getOAuthProvidersByUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OauthProvider
for rows.Next() {
var i OauthProvider
if err := rows.Scan(
&i.Provider,
&i.ProviderID,
&i.UserID,
&i.Email,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const insertOAuthProvider = `-- name: InsertOAuthProvider :exec
INSERT INTO oauth_providers (provider, provider_id, user_id, email)
VALUES ($1, $2, $3, $4)

View File

@ -190,7 +190,7 @@ func (q *Queries) InsertSandbox(ctx context.Context, arg InsertSandboxParams) (S
const listActiveSandboxesByTeam = `-- name: ListActiveSandboxesByTeam :many
SELECT id, team_id, host_id, template, status, vcpus, memory_mb, timeout_sec, disk_size_mb, guest_ip, host_ip, created_at, started_at, last_active_at, last_updated, template_id, template_team_id, metadata FROM sandboxes
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting')
WHERE team_id = $1 AND status IN ('running', 'paused', 'starting', 'hibernated')
ORDER BY created_at DESC
`

View File

@ -284,6 +284,39 @@ func (q *Queries) InsertTeamMember(ctx context.Context, arg InsertTeamMemberPara
return err
}
const listSoleOwnedTeams = `-- name: ListSoleOwnedTeams :many
SELECT t.id FROM teams t
JOIN users_teams ut ON ut.team_id = t.id
WHERE ut.user_id = $1
AND ut.role = 'owner'
AND t.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM users_teams ut2
WHERE ut2.team_id = t.id AND ut2.user_id <> $1
)
`
// Returns teams where the user is the owner and no other members exist.
func (q *Queries) ListSoleOwnedTeams(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listSoleOwnedTeams, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []pgtype.UUID
for rows.Next() {
var id pgtype.UUID
if err := rows.Scan(&id); err != nil {
return nil, err
}
items = append(items, id)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTeamsAdmin = `-- name: ListTeamsAdmin :many
SELECT
t.id,

View File

@ -11,6 +11,35 @@ 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
WHERE ut.user_id = $1
AND ut.role = 'owner'
AND EXISTS (
SELECT 1 FROM users_teams ut2
WHERE ut2.team_id = ut.team_id AND ut2.user_id <> $1
)
`
func (q *Queries) CountUserOwnedTeamsWithOtherMembers(ctx context.Context, userID pgtype.UUID) (int32, error) {
row := q.db.QueryRow(ctx, countUserOwnedTeamsWithOtherMembers, userID)
var column_1 int32
err := row.Scan(&column_1)
return column_1, err
}
const countUsers = `-- name: CountUsers :one
SELECT COUNT(*) FROM users
`
@ -79,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) {
@ -99,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
}
@ -113,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) {
@ -127,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) {
@ -148,12 +177,30 @@ 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
}
const hardDeleteExpiredUsers = `-- name: HardDeleteExpiredUsers :exec
DELETE FROM users WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '15 days'
`
func (q *Queries) HardDeleteExpiredUsers(ctx context.Context) error {
_, err := q.db.Exec(ctx, hardDeleteExpiredUsers)
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
@ -191,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 {
@ -217,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
}
@ -226,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 {
@ -246,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
}
@ -258,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
@ -278,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"`
@ -298,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,
@ -342,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
`
@ -370,6 +438,29 @@ 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(), status = 'deleted', updated_at = NOW() WHERE id = $1
`
func (q *Queries) SoftDeleteUser(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, softDeleteUser, id)
return err
}
const updateUserName = `-- name: UpdateUserName :exec
UPDATE users SET name = $2, updated_at = NOW() WHERE id = $1
`
@ -383,3 +474,17 @@ func (q *Queries) UpdateUserName(ctx context.Context, arg UpdateUserNameParams)
_, err := q.db.Exec(ctx, updateUserName, arg.ID, arg.Name)
return err
}
const updateUserPassword = `-- name: UpdateUserPassword :exec
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1
`
type UpdateUserPasswordParams struct {
ID pgtype.UUID `json:"id"`
PasswordHash pgtype.Text `json:"password_hash"`
}
func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPasswordParams) error {
_, err := q.db.Exec(ctx, updateUserPassword, arg.ID, arg.PasswordHash)
return err
}

View File

@ -169,6 +169,12 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
return fmt.Errorf("forbidden: only the owner can delete a team")
}
return s.deleteTeamCore(ctx, teamID)
}
// deleteTeamCore contains the shared team deletion logic:
// destroy active sandboxes, clean up templates, soft-delete the team.
func (s *TeamService) deleteTeamCore(ctx context.Context, teamID pgtype.UUID) error {
// Collect active sandboxes and stop them.
sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID)
if err != nil {
@ -202,6 +208,24 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID, callerUserID pgtyp
}
}
// Delete sandbox metrics for this team.
if err := s.DB.DeleteMetricPointsByTeam(ctx, teamID); err != nil {
slog.Warn("team delete: failed to delete metric points", "team_id", id.FormatTeamID(teamID), "error", err)
}
if err := s.DB.DeleteMetricsSnapshotsByTeam(ctx, teamID); err != nil {
slog.Warn("team delete: failed to delete metrics snapshots", "team_id", id.FormatTeamID(teamID), "error", err)
}
// Delete all API keys for this team.
if err := s.DB.DeleteAPIKeysByTeam(ctx, teamID); err != nil {
slog.Warn("team delete: failed to delete API keys", "team_id", id.FormatTeamID(teamID), "error", err)
}
// Delete all channels for this team.
if err := s.DB.DeleteAllChannelsByTeam(ctx, teamID); err != nil {
slog.Warn("team delete: failed to delete channels", "team_id", id.FormatTeamID(teamID), "error", err)
}
// Clean up team-owned templates from all hosts in the background.
go s.cleanupTeamTemplates(context.Background(), teamID)
@ -497,6 +521,13 @@ func (s *TeamService) AdminListTeams(ctx context.Context, limit, offset int32) (
return rows, total, nil
}
// DeleteTeamInternal soft-deletes a team and destroys all its active sandboxes.
// Used for system-initiated deletions (e.g. cascading from user account deletion)
// where no caller role check is needed.
func (s *TeamService) DeleteTeamInternal(ctx context.Context, teamID pgtype.UUID) error {
return s.deleteTeamCore(ctx, teamID)
}
// AdminDeleteTeam soft-deletes a team and destroys all its active sandboxes.
// Unlike DeleteTeam, this does not require the caller to be the team owner —
// it is admin-only (caller must verify admin status).
@ -509,41 +540,5 @@ func (s *TeamService) AdminDeleteTeam(ctx context.Context, teamID pgtype.UUID) e
return fmt.Errorf("team not found")
}
// Destroy active sandboxes (same logic as DeleteTeam).
sandboxes, err := s.DB.ListActiveSandboxesByTeam(ctx, teamID)
if err != nil {
return fmt.Errorf("list active sandboxes: %w", err)
}
var stopIDs []pgtype.UUID
for _, sb := range sandboxes {
host, hostErr := s.DB.GetHost(ctx, sb.HostID)
if hostErr == nil {
agent, agentErr := s.HostPool.GetForHost(host)
if agentErr == nil {
if _, err := agent.DestroySandbox(ctx, connect.NewRequest(&pb.DestroySandboxRequest{
SandboxId: id.FormatSandboxID(sb.ID),
})); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
slog.Warn("admin team delete: failed to destroy sandbox", "sandbox_id", id.FormatSandboxID(sb.ID), "error", err)
}
}
}
stopIDs = append(stopIDs, sb.ID)
}
if len(stopIDs) > 0 {
if err := s.DB.BulkUpdateStatusByIDs(ctx, db.BulkUpdateStatusByIDsParams{
Column1: stopIDs,
Status: "stopped",
}); err != nil {
return fmt.Errorf("update sandbox statuses: %w", err)
}
}
go s.cleanupTeamTemplates(context.Background(), teamID)
if err := s.DB.SoftDeleteTeam(ctx, teamID); err != nil {
return fmt.Errorf("soft delete team: %w", err)
}
return nil
return s.deleteTeamCore(ctx, teamID)
}

View File

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

View File

View File

View File

@ -1,233 +0,0 @@
#!/usr/bin/env bash
#
# test-host.sh — Integration test for the Wrenn host agent.
#
# Prerequisites:
# - Host agent running: sudo ./builds/wrenn-agent
# - Firecracker installed at /usr/local/bin/firecracker
# - Kernel at /var/lib/wrenn/kernels/vmlinux
# - Base rootfs at /var/lib/wrenn/images/minimal.ext4 (with envd + wrenn-init baked in)
#
# Usage:
# ./scripts/test-host.sh [agent_url]
#
# The agent URL defaults to http://localhost:50051.
set -euo pipefail
AGENT="${1:-http://localhost:50051}"
BASE="/hostagent.v1.HostAgentService"
SANDBOX_ID=""
# Colors for output.
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
pass() { echo -e "${GREEN}PASS${NC}: $1"; }
fail() { echo -e "${RED}FAIL${NC}: $1"; exit 1; }
info() { echo -e "${YELLOW}----${NC}: $1"; }
rpc() {
local method="$1"
local body="$2"
curl -s -X POST \
-H "Content-Type: application/json" \
"${AGENT}${BASE}/${method}" \
-d "${body}"
}
# ──────────────────────────────────────────────────
# Test 1: List sandboxes (should be empty)
# ──────────────────────────────────────────────────
info "Test 1: List sandboxes (expect empty)"
RESULT=$(rpc "ListSandboxes" '{}')
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -q '"sandboxes"' || echo "${RESULT}" | grep -q '{}' && \
pass "ListSandboxes returned" || \
fail "ListSandboxes failed"
# ──────────────────────────────────────────────────
# Test 2: Create a sandbox
# ──────────────────────────────────────────────────
info "Test 2: Create a sandbox"
RESULT=$(rpc "CreateSandbox" '{
"template": "minimal",
"vcpus": 1,
"memoryMb": 512,
"timeoutSec": 300
}')
echo " Response: ${RESULT}"
SANDBOX_ID=$(echo "${RESULT}" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandboxId'])" 2>/dev/null) || \
fail "CreateSandbox did not return sandboxId"
echo " Sandbox ID: ${SANDBOX_ID}"
pass "Sandbox created: ${SANDBOX_ID}"
# ──────────────────────────────────────────────────
# Test 3: List sandboxes (should have one)
# ──────────────────────────────────────────────────
info "Test 3: List sandboxes (expect one)"
RESULT=$(rpc "ListSandboxes" '{}')
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -q "${SANDBOX_ID}" && \
pass "Sandbox ${SANDBOX_ID} found in list" || \
fail "Sandbox not found in list"
# ──────────────────────────────────────────────────
# Test 4: Execute a command
# ──────────────────────────────────────────────────
info "Test 4: Execute 'echo hello world'"
RESULT=$(rpc "Exec" "{
\"sandboxId\": \"${SANDBOX_ID}\",
\"cmd\": \"/bin/sh\",
\"args\": [\"-c\", \"echo hello world\"],
\"timeoutSec\": 10
}")
echo " Response: ${RESULT}"
# stdout is base64-encoded in Connect RPC JSON.
STDOUT=$(echo "${RESULT}" | python3 -c "
import sys, json, base64
r = json.load(sys.stdin)
print(base64.b64decode(r['stdout']).decode().strip())
" 2>/dev/null) || fail "Exec did not return stdout"
[ "${STDOUT}" = "hello world" ] && \
pass "Exec returned correct output: '${STDOUT}'" || \
fail "Expected 'hello world', got '${STDOUT}'"
# ──────────────────────────────────────────────────
# Test 5: Execute a multi-line command
# ──────────────────────────────────────────────────
info "Test 5: Execute multi-line command"
RESULT=$(rpc "Exec" "{
\"sandboxId\": \"${SANDBOX_ID}\",
\"cmd\": \"/bin/sh\",
\"args\": [\"-c\", \"echo line1; echo line2; echo line3\"],
\"timeoutSec\": 10
}")
echo " Response: ${RESULT}"
LINE_COUNT=$(echo "${RESULT}" | python3 -c "
import sys, json, base64
r = json.load(sys.stdin)
lines = base64.b64decode(r['stdout']).decode().strip().split('\n')
print(len(lines))
" 2>/dev/null)
[ "${LINE_COUNT}" = "3" ] && \
pass "Multi-line output: ${LINE_COUNT} lines" || \
fail "Expected 3 lines, got ${LINE_COUNT}"
# ──────────────────────────────────────────────────
# Test 6: Pause the sandbox
# ──────────────────────────────────────────────────
info "Test 6: Pause sandbox"
RESULT=$(rpc "PauseSandbox" "{\"sandboxId\": \"${SANDBOX_ID}\"}")
echo " Response: ${RESULT}"
# Verify status is paused.
LIST=$(rpc "ListSandboxes" '{}')
echo "${LIST}" | grep -q '"paused"' && \
pass "Sandbox paused" || \
fail "Sandbox not in paused state"
# ──────────────────────────────────────────────────
# Test 7: Exec should fail while paused
# ──────────────────────────────────────────────────
info "Test 7: Exec while paused (expect error)"
RESULT=$(rpc "Exec" "{
\"sandboxId\": \"${SANDBOX_ID}\",
\"cmd\": \"/bin/echo\",
\"args\": [\"should fail\"]
}")
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -qi "not running\|error\|code" && \
pass "Exec correctly rejected while paused" || \
fail "Exec should have failed while paused"
# ──────────────────────────────────────────────────
# Test 8: Resume the sandbox
# ──────────────────────────────────────────────────
info "Test 8: Resume sandbox"
RESULT=$(rpc "ResumeSandbox" "{\"sandboxId\": \"${SANDBOX_ID}\"}")
echo " Response: ${RESULT}"
# Verify status is running.
LIST=$(rpc "ListSandboxes" '{}')
echo "${LIST}" | grep -q '"running"' && \
pass "Sandbox resumed" || \
fail "Sandbox not in running state"
# ──────────────────────────────────────────────────
# Test 9: Exec after resume
# ──────────────────────────────────────────────────
info "Test 9: Exec after resume"
RESULT=$(rpc "Exec" "{
\"sandboxId\": \"${SANDBOX_ID}\",
\"cmd\": \"/bin/sh\",
\"args\": [\"-c\", \"echo resumed ok\"],
\"timeoutSec\": 10
}")
echo " Response: ${RESULT}"
STDOUT=$(echo "${RESULT}" | python3 -c "
import sys, json, base64
r = json.load(sys.stdin)
print(base64.b64decode(r['stdout']).decode().strip())
" 2>/dev/null) || fail "Exec after resume failed"
[ "${STDOUT}" = "resumed ok" ] && \
pass "Exec after resume works: '${STDOUT}'" || \
fail "Expected 'resumed ok', got '${STDOUT}'"
# ──────────────────────────────────────────────────
# Test 10: Destroy the sandbox
# ──────────────────────────────────────────────────
info "Test 10: Destroy sandbox"
RESULT=$(rpc "DestroySandbox" "{\"sandboxId\": \"${SANDBOX_ID}\"}")
echo " Response: ${RESULT}"
pass "Sandbox destroyed"
# ──────────────────────────────────────────────────
# Test 11: List sandboxes (should be empty again)
# ──────────────────────────────────────────────────
info "Test 11: List sandboxes (expect empty)"
RESULT=$(rpc "ListSandboxes" '{}')
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -q "${SANDBOX_ID}" && \
fail "Destroyed sandbox still in list" || \
pass "Sandbox list is clean"
# ──────────────────────────────────────────────────
# Test 12: Destroy non-existent sandbox (expect error)
# ──────────────────────────────────────────────────
info "Test 12: Destroy non-existent sandbox (expect error)"
RESULT=$(rpc "DestroySandbox" '{"sandboxId": "sb-nonexist"}')
echo " Response: ${RESULT}"
echo "${RESULT}" | grep -qi "not found\|error\|code" && \
pass "Correctly rejected non-existent sandbox" || \
fail "Should have returned error for non-existent sandbox"
echo ""
echo -e "${GREEN}All tests passed!${NC}"