diff --git a/CLAUDE.md b/CLAUDE.md index 05123ef..c104a7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/db/migrations/20260415215033_replace_is_active_with_status.sql b/db/migrations/20260415215033_replace_is_active_with_status.sql new file mode 100644 index 0000000..2ea091a --- /dev/null +++ b/db/migrations/20260415215033_replace_is_active_with_status.sql @@ -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; diff --git a/db/migrations/20260415221116_cascade_user_delete.sql b/db/migrations/20260415221116_cascade_user_delete.sql new file mode 100644 index 0000000..ac01674 --- /dev/null +++ b/db/migrations/20260415221116_cascade_user_delete.sql @@ -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); diff --git a/db/queries/api_keys.sql b/db/queries/api_keys.sql index 7ea9645..be064a1 100644 --- a/db/queries/api_keys.sql +++ b/db/queries/api_keys.sql @@ -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; diff --git a/db/queries/channels.sql b/db/queries/channels.sql index 5772c99..6df0449 100644 --- a/db/queries/channels.sql +++ b/db/queries/channels.sql @@ -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 diff --git a/db/queries/metrics.sql b/db/queries/metrics.sql index f58d480..6c612c6 100644 --- a/db/queries/metrics.sql +++ b/db/queries/metrics.sql @@ -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 diff --git a/db/queries/oauth.sql b/db/queries/oauth.sql index 31b1ff8..9dc7929 100644 --- a/db/queries/oauth.sql +++ b/db/queries/oauth.sql @@ -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; diff --git a/db/queries/sandboxes.sql b/db/queries/sandboxes.sql index 73843f8..2bf5db7 100644 --- a/db/queries/sandboxes.sql +++ b/db/queries/sandboxes.sql @@ -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 diff --git a/db/queries/teams.sql b/db/queries/teams.sql index d94341d..3444b8c 100644 --- a/db/queries/teams.sql +++ b/db/queries/teams.sql @@ -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 diff --git a/db/queries/users.sql b/db/queries/users.sql index bd7d85a..1c902a3 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -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; diff --git a/deploy/ansible/playbook.yml b/deploy/ansible/playbook.yml deleted file mode 100644 index e69de29..0000000 diff --git a/deploy/systemd/wrenn-control-plane.service b/deploy/systemd/wrenn-control-plane.service deleted file mode 100644 index e69de29..0000000 diff --git a/deploy/systemd/wrenn-host-agent.service b/deploy/systemd/wrenn-host-agent.service deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/lib/api/admin-users.ts b/frontend/src/lib/api/admin-users.ts index a8cfa19..c5dd339 100644 --- a/frontend/src/lib/api/admin-users.ts +++ b/frontend/src/lib/api/admin-users.ts @@ -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; diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index 845b8a3..1a3ede9 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -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 { return authFetch('/api/v1/auth/login', { email, password }); } -export async function apiSignup(email: string, password: string, name: string): Promise { +export async function apiSignup(email: string, password: string, name: string): Promise { return authFetch('/api/v1/auth/signup', { email, password, name }); } -async function authFetch(url: string, body: Record): Promise { +export async function apiActivate(token: string): Promise { + return authFetch('/api/v1/auth/activate', { token }); +} + +async function authFetch(url: string, body: Record): 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): Promise> => + apiFetch('GET', '/api/v1/me'); + +export const updateName = (name: string): Promise> => + apiFetch('PATCH', '/api/v1/me', { name }); + +export const changePassword = (body: ChangePasswordBody): Promise> => + apiFetch('POST', '/api/v1/me/password', body); + +export const requestPasswordReset = (email: string): Promise> => + apiFetch('POST', '/api/v1/me/password/reset', { email }); + +export const confirmPasswordReset = ( + token: string, + new_password: string +): Promise> => + apiFetch('POST', '/api/v1/me/password/reset/confirm', { token, new_password }); + +export const getProviderConnectURL = (provider: string): Promise> => + apiFetch('GET', `/api/v1/me/providers/${provider}/connect`); + +export const disconnectProvider = (provider: string): Promise> => + apiFetch('DELETE', `/api/v1/me/providers/${provider}`); + +export const deleteAccount = (confirmation: string): Promise> => + apiFetch('DELETE', '/api/v1/me', { confirmation }); diff --git a/frontend/src/lib/components/Sidebar.svelte b/frontend/src/lib/components/Sidebar.svelte index 911f3ac..47d2960 100644 --- a/frontend/src/lib/components/Sidebar.svelte +++ b/frontend/src/lib/components/Sidebar.svelte @@ -280,13 +280,21 @@ {#if !collapsed}Notifications{/if} -
- - {#if !collapsed}Settings{/if} -
+ {#if isActive('/dashboard/settings') && !collapsed} +
+ {/if} + + {#if !collapsed} + + Settings + + {/if} + diff --git a/frontend/src/routes/activate/+page.svelte b/frontend/src/routes/activate/+page.svelte new file mode 100644 index 0000000..14bea92 --- /dev/null +++ b/frontend/src/routes/activate/+page.svelte @@ -0,0 +1,75 @@ + + + + Wrenn — Activate account + + +
+
+ +
+ Wrenn + Wrenn +
+ + {#if loading} +
+
+ +

Activating your account...

+
+
+ {:else if error} +
+

Activation failed

+

{error}

+ + Back to sign in + +
+ {:else if done} +
+
+ +

Redirecting to dashboard...

+
+
+ {/if} +
+
diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte index 3e8c46f..e933dc3 100644 --- a/frontend/src/routes/admin/users/+page.svelte +++ b/frontend/src/routes/admin/users/+page.svelte @@ -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)}
- {#if user.is_active} + {#if user.status === 'active'}
{/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} {:else} - {user.is_active ? 'Active' : 'Inactive'} + {user.status === 'active' ? 'Active' : user.status.charAt(0).toUpperCase() + user.status.slice(1)} {/if}
diff --git a/frontend/src/routes/dashboard/settings/+page.svelte b/frontend/src/routes/dashboard/settings/+page.svelte new file mode 100644 index 0000000..490b8b1 --- /dev/null +++ b/frontend/src/routes/dashboard/settings/+page.svelte @@ -0,0 +1,673 @@ + + + + Wrenn — Settings + + +
+ + +
+
+ +
+
+

Settings

+

+ Manage your account details and security. +

+
+
+
+ + +
+ {#if loadError} +
+ {loadError} +
+ {:else if me} +
+ + +
+
+
+ {initials} +
+
+

Profile

+

How you appear across Wrenn.

+
+
+ +
+
+ + +
+ +
+ + Email + +
+ {me.email} +
+
+ + {#if nameError} +

{nameError}

+ {/if} + +
+ +
+
+
+ +
+ + +
+
+
+ +
+
+

+ {me.has_password ? 'Change password' : 'Add a password'} +

+

+ {me.has_password + ? 'Use a strong, unique password you don\'t use elsewhere.' + : 'Set a password so you can sign in with your email.'} +

+
+
+ +
+ {#if me.has_password} +
+ + +
+ {/if} + +
+ + +
+ + {#if !me.has_password} +
+ + +
+ {/if} + + {#if passwordError} +

{passwordError}

+ {/if} + +
+ {#if me.has_password} + + {:else} + + {/if} + + +
+
+
+ +
+ + +
+
+
+ +
+
+

Connected accounts

+

+ Sign in with a linked account instead of your password. +

+
+
+ +
+ +
+
+ + + + +
+
GitHub
+ {#if me.providers.includes('github')} +
+ + Connected +
+ {:else} +
Not connected
+ {/if} +
+
+ + {#if me.providers.includes('github')} + + {:else} + + {/if} +
+
+
+ +
+ + +
+

Danger zone

+

+ Deleting your account is irreversible. +

+ +
+
+
+
Delete account
+
+ Your account will be deactivated immediately and permanently removed after 15 days. +
+
+ +
+
+
+ +
+ {:else} + +
+
+
+
+
+
+
+
+ {#each [140, 180, 100] as h, i} +
+
+
+ {/each} +
+ {/if} +
+
+
+
+
+ + +{#if showDisconnectConfirm} +
+ +
{ if (!disconnectingGitHub) showDisconnectConfirm = false; }} + onkeydown={(e) => { if (e.key === 'Escape' && !disconnectingGitHub) showDisconnectConfirm = false; }} + >
+
+

Disconnect GitHub

+

+ You won't be able to sign in with GitHub. You can reconnect it later. +

+ + {#if disconnectError} +
+ {disconnectError} +
+ {/if} + +
+ + +
+
+
+{/if} + + +{#if showDeleteConfirm} +
+ +
{ if (!deleting) showDeleteConfirm = false; }} + onkeydown={(e) => { if (e.key === 'Escape' && !deleting) showDeleteConfirm = false; }} + >
+
+

Delete account

+

+ Your account will be deactivated immediately and permanently deleted after 15 days. This cannot be undone. +

+ + {#if deleteError} +
+ {deleteError} +
+ {/if} + +
+ + +
+ +
+ + +
+
+
+{/if} + + diff --git a/frontend/src/routes/forgot-password/+page.svelte b/frontend/src/routes/forgot-password/+page.svelte new file mode 100644 index 0000000..0cd8730 --- /dev/null +++ b/frontend/src/routes/forgot-password/+page.svelte @@ -0,0 +1,99 @@ + + + + Wrenn — Reset password + + +
+
+ +
+ Wrenn + Wrenn +
+ + {#if submitted} +
+

Check your email

+

+ If an account exists for {email}, you'll receive a reset link shortly. The link expires in 15 minutes. +

+ + Back to sign in + +
+ {:else} +
+

Reset your password

+

+ Enter your email and we'll send you a reset link. +

+ + {#if error} +

{error}

+ {/if} + +
+
+ + +
+ + +
+ + + Back to sign in + +
+ {/if} +
+
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 914bc70..2751add 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -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 = { 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 @@
- -
-

+
+

Check your email

+

+ We've sent an activation link to {email}. Click the link to activate your account. +

+

+ The link expires in 30 minutes. If you don't see it, check your spam folder. +

+ +
+ {:else} + +
+

+ {title} +

+

+ {subtitle} +

+
+ + + - {title} -

-

- {subtitle} -

-
+ + Continue with GitHub + - - - - Continue with GitHub - + +
+
+ or +
+
- -
-
- or -
-
- - -
- {#if mode === 'signup'} + + + {#if mode === 'signup'} +
+
+ +
+ +
+ {/if}
- +
- {/if} -
-
- -
- -
-
-
- -
- - -
- - {#if mode === 'signin'} -
+
+
+ +
+
- {/if} - {#if error} -

{error}

- {/if} - - - - -

- {switchText} - -

+ {#if mode === 'signin'} + + {/if} + + {#if error} +

{error}

+ {/if} + + + + + +

+ {switchText} + +

+ {/if}
diff --git a/frontend/src/routes/reset-password/+page.svelte b/frontend/src/routes/reset-password/+page.svelte new file mode 100644 index 0000000..8dfbd12 --- /dev/null +++ b/frontend/src/routes/reset-password/+page.svelte @@ -0,0 +1,138 @@ + + + + Wrenn — Set new password + + +
+
+ +
+ Wrenn + Wrenn +
+ + {#if done} +
+

All set

+

+ Your password has been updated. Sign in to continue. +

+ + Sign in + +
+ {:else} +
+

Set new password

+

Must be at least 8 characters.

+ + {#if error} +

{error}

+ {/if} + +
+
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+
+ {/if} + + + Back to sign in + +
+
diff --git a/internal/api/handlers_apikeys.go b/internal/api/handlers_apikeys.go index a4c077a..9fc315d 100644 --- a/internal/api/handlers_apikeys.go +++ b/internal/api/handlers_apikeys.go @@ -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) diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index d2d8d53..1768606 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -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[:]) +} diff --git a/internal/api/handlers_me.go b/internal/api/handlers_me.go new file mode 100644 index 0000000..aefb7d7 --- /dev/null +++ b/internal/api/handlers_me.go @@ -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[:]) +} diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go index eef0977..98aac78 100644 --- a/internal/api/handlers_oauth.go +++ b/internal/api/handlers_oauth.go @@ -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 } diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 23b4b53..f8a8b67 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -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 } diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go index b328e40..6122ce8 100644 --- a/internal/api/middleware_auth.go +++ b/internal/api/middleware_auth.go @@ -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 } diff --git a/internal/api/middleware_jwt.go b/internal/api/middleware_jwt.go index c0f4260..1b28405 100644 --- a/internal/api/middleware_jwt.go +++ b/internal/api/middleware_jwt.go @@ -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 } diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index 984a37d..f4c369d 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -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: diff --git a/internal/api/server.go b/internal/api/server.go index c687f5f..6433644 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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) diff --git a/internal/email/templates/base.html b/internal/email/templates/base.html index 861bd71..ad6f57f 100644 --- a/internal/email/templates/base.html +++ b/internal/email/templates/base.html @@ -28,12 +28,21 @@ - + - @@ -42,21 +51,21 @@
+ - Wrenn + + + + + +
+ Wrenn + + Wrenn +
-
+ -

+

Hello{{if .RecipientName}} {{.RecipientName}}{{end}},

-

+

{{.Message}}

{{if .Button}} - +
- + {{.Button.Text}} @@ -74,7 +83,7 @@
-

+

If the button doesn't work, copy and paste this URL into your browser:
{{.Button.URL}}

@@ -82,7 +91,7 @@ {{if .Closing}} -

+

{{.Closing}}

{{end}} @@ -94,7 +103,7 @@ -
+

This is a transactional email from Wrenn.

diff --git a/pkg/cpserver/run.go b/pkg/cpserver/run.go index d7be9ad..aeb21ac 100644 --- a/pkg/cpserver/run.go +++ b/pkg/cpserver/run.go @@ -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) diff --git a/pkg/db/api_keys.sql.go b/pkg/db/api_keys.sql.go index 4b8d369..55f1bce 100644 --- a/pkg/db/api_keys.sql.go +++ b/pkg/db/api_keys.sql.go @@ -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) { diff --git a/pkg/db/channels.sql.go b/pkg/db/channels.sql.go index 18f9048..d668700 100644 --- a/pkg/db/channels.sql.go +++ b/pkg/db/channels.sql.go @@ -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 ` diff --git a/pkg/db/metrics.sql.go b/pkg/db/metrics.sql.go index f522dc2..886daca 100644 --- a/pkg/db/metrics.sql.go +++ b/pkg/db/metrics.sql.go @@ -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 diff --git a/pkg/db/models.go b/pkg/db/models.go index 5e1128a..3111952 100644 --- a/pkg/db/models.go +++ b/pkg/db/models.go @@ -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 { diff --git a/pkg/db/oauth.sql.go b/pkg/db/oauth.sql.go index 0270def..724277e 100644 --- a/pkg/db/oauth.sql.go +++ b/pkg/db/oauth.sql.go @@ -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) diff --git a/pkg/db/sandboxes.sql.go b/pkg/db/sandboxes.sql.go index e33818d..c48c9ab 100644 --- a/pkg/db/sandboxes.sql.go +++ b/pkg/db/sandboxes.sql.go @@ -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 ` diff --git a/pkg/db/teams.sql.go b/pkg/db/teams.sql.go index 0700db4..7947107 100644 --- a/pkg/db/teams.sql.go +++ b/pkg/db/teams.sql.go @@ -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, diff --git a/pkg/db/users.sql.go b/pkg/db/users.sql.go index 73ebe52..2bfb6ed 100644 --- a/pkg/db/users.sql.go +++ b/pkg/db/users.sql.go @@ -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 +} diff --git a/pkg/service/team.go b/pkg/service/team.go index 858c7e2..0376406 100644 --- a/pkg/service/team.go +++ b/pkg/service/team.go @@ -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) } diff --git a/pkg/service/user.go b/pkg/service/user.go index b585e23..db687c0 100644 --- a/pkg/service/user.go +++ b/pkg/service/user.go @@ -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 } diff --git a/scripts/dev.sh b/scripts/dev.sh deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/generate-proto.sh b/scripts/generate-proto.sh deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/setup-host.sh b/scripts/setup-host.sh deleted file mode 100644 index e69de29..0000000 diff --git a/scripts/test-host.sh b/scripts/test-host.sh deleted file mode 100755 index 1cd61e7..0000000 --- a/scripts/test-host.sh +++ /dev/null @@ -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}" diff --git a/scripts/update-debug-rootfs.sh b/scripts/update-minimal-rootfs.sh similarity index 100% rename from scripts/update-debug-rootfs.sh rename to scripts/update-minimal-rootfs.sh