forked from wrenn/wrenn
Merge pull request 'Added settings for users and proper email flow for authentication' (#30) from feat/user-onboarding into dev
Reviewed-on: wrenn/wrenn#30
This commit is contained in:
107
CLAUDE.md
107
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
|
||||
|
||||
|
||||
@ -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;
|
||||
72
db/migrations/20260415221116_cascade_user_delete.sql
Normal file
72
db/migrations/20260415221116_cascade_user_delete.sql
Normal file
@ -0,0 +1,72 @@
|
||||
-- +goose Up
|
||||
|
||||
-- users_teams: remove membership when user is deleted
|
||||
ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey;
|
||||
ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- oauth_providers: remove auth links when user is deleted
|
||||
ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey;
|
||||
ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- admin_permissions: remove permissions when user is deleted
|
||||
ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey;
|
||||
ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- team_api_keys.created_by: make nullable, SET NULL on user delete
|
||||
ALTER TABLE team_api_keys ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey;
|
||||
ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- hosts.created_by: make nullable, SET NULL on user delete
|
||||
ALTER TABLE hosts ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey;
|
||||
ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- host_tokens.created_by: make nullable, SET NULL on user delete
|
||||
ALTER TABLE host_tokens ALTER COLUMN created_by DROP NOT NULL;
|
||||
ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey;
|
||||
ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
-- Revert host_tokens.created_by
|
||||
ALTER TABLE host_tokens DROP CONSTRAINT host_tokens_created_by_fkey;
|
||||
UPDATE host_tokens SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
|
||||
ALTER TABLE host_tokens ALTER COLUMN created_by SET NOT NULL;
|
||||
ALTER TABLE host_tokens ADD CONSTRAINT host_tokens_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||
|
||||
-- Revert hosts.created_by
|
||||
ALTER TABLE hosts DROP CONSTRAINT hosts_created_by_fkey;
|
||||
UPDATE hosts SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
|
||||
ALTER TABLE hosts ALTER COLUMN created_by SET NOT NULL;
|
||||
ALTER TABLE hosts ADD CONSTRAINT hosts_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||
|
||||
-- Revert team_api_keys.created_by
|
||||
ALTER TABLE team_api_keys DROP CONSTRAINT team_api_keys_created_by_fkey;
|
||||
UPDATE team_api_keys SET created_by = '00000000-0000-0000-0000-000000000000' WHERE created_by IS NULL;
|
||||
ALTER TABLE team_api_keys ALTER COLUMN created_by SET NOT NULL;
|
||||
ALTER TABLE team_api_keys ADD CONSTRAINT team_api_keys_created_by_fkey
|
||||
FOREIGN KEY (created_by) REFERENCES users(id);
|
||||
|
||||
-- Revert admin_permissions
|
||||
ALTER TABLE admin_permissions DROP CONSTRAINT admin_permissions_user_id_fkey;
|
||||
ALTER TABLE admin_permissions ADD CONSTRAINT admin_permissions_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
-- Revert oauth_providers
|
||||
ALTER TABLE oauth_providers DROP CONSTRAINT oauth_providers_user_id_fkey;
|
||||
ALTER TABLE oauth_providers ADD CONSTRAINT oauth_providers_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
-- Revert users_teams
|
||||
ALTER TABLE users_teams DROP CONSTRAINT users_teams_user_id_fkey;
|
||||
ALTER TABLE users_teams ADD CONSTRAINT users_teams_user_id_fkey
|
||||
FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -5,7 +5,7 @@ export type AdminUser = {
|
||||
email: string;
|
||||
name: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
status: string;
|
||||
created_at: string;
|
||||
teams_joined: number;
|
||||
teams_owned: number;
|
||||
|
||||
@ -6,17 +6,26 @@ export type AuthResponse = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type SignupResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AuthResult = { ok: true; data: AuthResponse } | { ok: false; error: string };
|
||||
export type SignupResult = { ok: true; data: SignupResponse } | { ok: false; error: string };
|
||||
|
||||
export async function apiLogin(email: string, password: string): Promise<AuthResult> {
|
||||
return authFetch('/api/v1/auth/login', { email, password });
|
||||
}
|
||||
|
||||
export async function apiSignup(email: string, password: string, name: string): Promise<AuthResult> {
|
||||
export async function apiSignup(email: string, password: string, name: string): Promise<SignupResult> {
|
||||
return authFetch('/api/v1/auth/signup', { email, password, name });
|
||||
}
|
||||
|
||||
async function authFetch(url: string, body: Record<string, string>): Promise<AuthResult> {
|
||||
export async function apiActivate(token: string): Promise<AuthResult> {
|
||||
return authFetch('/api/v1/auth/activate', { token });
|
||||
}
|
||||
|
||||
async function authFetch<T = AuthResponse>(url: string, body: Record<string, string>): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@ -31,7 +40,7 @@ async function authFetch(url: string, body: Record<string, string>): Promise<Aut
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
|
||||
return { ok: true, data: data as AuthResponse };
|
||||
return { ok: true, data: data as T };
|
||||
} catch {
|
||||
return { ok: false, error: 'Unable to connect to the server' };
|
||||
}
|
||||
|
||||
42
frontend/src/lib/api/me.ts
Normal file
42
frontend/src/lib/api/me.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { apiFetch, type ApiResult } from '$lib/api/client';
|
||||
import type { AuthResponse } from '$lib/api/auth';
|
||||
|
||||
export type MeResponse = {
|
||||
name: string;
|
||||
email: string;
|
||||
has_password: boolean;
|
||||
providers: string[];
|
||||
};
|
||||
|
||||
export type ChangePasswordBody = {
|
||||
current_password?: string;
|
||||
new_password: string;
|
||||
confirm_password?: string;
|
||||
};
|
||||
|
||||
export const getMe = (): Promise<ApiResult<MeResponse>> =>
|
||||
apiFetch('GET', '/api/v1/me');
|
||||
|
||||
export const updateName = (name: string): Promise<ApiResult<AuthResponse>> =>
|
||||
apiFetch('PATCH', '/api/v1/me', { name });
|
||||
|
||||
export const changePassword = (body: ChangePasswordBody): Promise<ApiResult<void>> =>
|
||||
apiFetch('POST', '/api/v1/me/password', body);
|
||||
|
||||
export const requestPasswordReset = (email: string): Promise<ApiResult<void>> =>
|
||||
apiFetch('POST', '/api/v1/me/password/reset', { email });
|
||||
|
||||
export const confirmPasswordReset = (
|
||||
token: string,
|
||||
new_password: string
|
||||
): Promise<ApiResult<void>> =>
|
||||
apiFetch('POST', '/api/v1/me/password/reset/confirm', { token, new_password });
|
||||
|
||||
export const getProviderConnectURL = (provider: string): Promise<ApiResult<{ auth_url: string }>> =>
|
||||
apiFetch('GET', `/api/v1/me/providers/${provider}/connect`);
|
||||
|
||||
export const disconnectProvider = (provider: string): Promise<ApiResult<void>> =>
|
||||
apiFetch('DELETE', `/api/v1/me/providers/${provider}`);
|
||||
|
||||
export const deleteAccount = (confirmation: string): Promise<ApiResult<void>> =>
|
||||
apiFetch('DELETE', '/api/v1/me', { confirmation });
|
||||
@ -280,13 +280,21 @@
|
||||
<IconBell size={16} class="shrink-0" />
|
||||
{#if !collapsed}<span class="text-ui">Notifications</span>{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex cursor-not-allowed items-center rounded-[var(--radius-input)] px-2.5 py-2.5 opacity-35 {collapsed ? 'justify-center px-2' : 'gap-3'}"
|
||||
title={collapsed ? 'Settings (coming soon)' : 'Coming soon'}
|
||||
<a
|
||||
href="/dashboard/settings"
|
||||
class="group relative flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'} {isActive('/dashboard/settings') ? (collapsed ? 'bg-[var(--color-accent-glow-mid)]' : 'bg-[var(--color-accent)]/[0.12]') : ''}"
|
||||
title={collapsed ? 'Settings' : undefined}
|
||||
>
|
||||
<IconSettings size={16} class="shrink-0" />
|
||||
{#if !collapsed}<span class="text-ui">Settings</span>{/if}
|
||||
</div>
|
||||
{#if isActive('/dashboard/settings') && !collapsed}
|
||||
<div class="absolute left-0 top-1/2 h-6 w-1 -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
|
||||
{/if}
|
||||
<IconSettings size={16} class="shrink-0 {isActive('/dashboard/settings') ? 'text-[var(--color-accent-bright)]' : 'opacity-50 transition-opacity duration-150 group-hover:opacity-100'}" />
|
||||
{#if !collapsed}
|
||||
<span class="text-ui transition-colors duration-150 {isActive('/dashboard/settings') ? 'font-semibold text-[var(--color-accent-bright)]' : 'text-[var(--color-text-primary)] group-hover:text-[var(--color-text-bright)]'}">
|
||||
Settings
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- User footer -->
|
||||
|
||||
75
frontend/src/routes/activate/+page.svelte
Normal file
75
frontend/src/routes/activate/+page.svelte
Normal file
@ -0,0 +1,75 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import { teams } from '$lib/teams.svelte';
|
||||
import { apiActivate } from '$lib/api/auth';
|
||||
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let done = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
const token = $page.url.searchParams.get('token');
|
||||
if (!token) {
|
||||
error = 'No activation token provided.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await apiActivate(token);
|
||||
loading = false;
|
||||
|
||||
if (!result.ok) {
|
||||
error = result.error;
|
||||
return;
|
||||
}
|
||||
|
||||
done = true;
|
||||
teams.reset();
|
||||
auth.login(result.data);
|
||||
goto('/dashboard');
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wrenn — Activate account</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[var(--color-bg-0)] px-4">
|
||||
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease both">
|
||||
<!-- Brand -->
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<img src="/logo.svg" alt="Wrenn" class="h-10 w-10 rounded-[var(--radius-logo)]" />
|
||||
<span class="font-brand text-page text-[var(--color-text-bright)]">Wrenn</span>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-accent)]/30 border-t-[var(--color-accent)]"></span>
|
||||
<p class="text-body text-[var(--color-text-secondary)]">Activating your account...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
|
||||
<h1 class="font-serif text-display text-[var(--color-text-bright)]">Activation failed</h1>
|
||||
<p class="mt-2 text-ui text-[var(--color-red)]">{error}</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="mt-6 flex w-full items-center justify-center rounded-[var(--radius-button)] bg-[var(--color-accent)] py-3 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
|
||||
>
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
{:else if done}
|
||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-accent)]/30 border-t-[var(--color-accent)]"></span>
|
||||
<p class="text-body text-[var(--color-text-secondary)]">Redirecting to dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@ -52,10 +52,10 @@
|
||||
|
||||
async function handleToggleActive(user: AdminUser) {
|
||||
togglingId = user.id;
|
||||
const newActive = !user.is_active;
|
||||
const newActive = user.status !== 'active';
|
||||
const result = await setUserActive(user.id, newActive);
|
||||
if (result.ok) {
|
||||
user.is_active = newActive;
|
||||
user.status = newActive ? 'active' : 'disabled';
|
||||
toast.success(`${user.email} ${newActive ? 'activated' : 'deactivated'}`);
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
@ -195,11 +195,11 @@
|
||||
{:else}
|
||||
{#each users as user, i (user.id)}
|
||||
<div
|
||||
class="user-row user-grid relative items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 last:border-b-0 {!user.is_active ? 'opacity-50' : 'hover:bg-[var(--color-bg-3)]'}"
|
||||
class="user-row user-grid relative items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 last:border-b-0 {user.status !== 'active' ? 'opacity-50' : 'hover:bg-[var(--color-bg-3)]'}"
|
||||
style={initialAnimationDone ? '' : `animation: fadeUp 0.35s ease both; animation-delay: ${i * 30}ms`}
|
||||
>
|
||||
<!-- Left accent stripe -->
|
||||
{#if user.is_active}
|
||||
{#if user.status === 'active'}
|
||||
<div class="row-stripe pointer-events-none absolute left-0 top-0 h-full w-0.5 bg-[var(--color-accent)]"></div>
|
||||
{/if}
|
||||
|
||||
@ -247,14 +247,14 @@
|
||||
onclick={() => handleToggleActive(user)}
|
||||
disabled={togglingId === user.id}
|
||||
class="rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-medium transition-all duration-150 disabled:opacity-50
|
||||
{user.is_active
|
||||
{user.status === 'active'
|
||||
? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/8 text-[var(--color-accent-bright)] hover:bg-[var(--color-accent)]/15 hover:border-[var(--color-accent)]/50'
|
||||
: 'border-[var(--color-red)]/30 bg-[var(--color-red)]/8 text-[var(--color-red)] hover:bg-[var(--color-red)]/15 hover:border-[var(--color-red)]/50'}"
|
||||
>
|
||||
{#if togglingId === user.id}
|
||||
<svg class="inline animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
{:else}
|
||||
{user.is_active ? 'Active' : 'Inactive'}
|
||||
{user.status === 'active' ? 'Active' : user.status.charAt(0).toUpperCase() + user.status.slice(1)}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
673
frontend/src/routes/dashboard/settings/+page.svelte
Normal file
673
frontend/src/routes/dashboard/settings/+page.svelte
Normal file
@ -0,0 +1,673 @@
|
||||
<script lang="ts">
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import {
|
||||
getMe,
|
||||
updateName,
|
||||
changePassword,
|
||||
requestPasswordReset,
|
||||
getProviderConnectURL,
|
||||
disconnectProvider,
|
||||
deleteAccount,
|
||||
type MeResponse
|
||||
} from '$lib/api/me';
|
||||
|
||||
let collapsed = $state(
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
|
||||
: false
|
||||
);
|
||||
|
||||
let me = $state<MeResponse | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
let initials = $derived(
|
||||
me?.name
|
||||
? me.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
||||
: me?.email?.[0]?.toUpperCase() ?? '?'
|
||||
);
|
||||
|
||||
// Profile
|
||||
let editName = $state('');
|
||||
let savingName = $state(false);
|
||||
let nameError = $state<string | null>(null);
|
||||
let nameSaved = $state(false);
|
||||
let nameSavedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Password
|
||||
let currentPassword = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let savingPassword = $state(false);
|
||||
let passwordError = $state<string | null>(null);
|
||||
let sendingReset = $state(false);
|
||||
let passwordSaved = $state(false);
|
||||
let passwordSavedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// GitHub connect/disconnect
|
||||
let connectingGitHub = $state(false);
|
||||
let disconnectingGitHub = $state(false);
|
||||
let showDisconnectConfirm = $state(false);
|
||||
let disconnectError = $state<string | null>(null);
|
||||
|
||||
// Delete account
|
||||
let showDeleteConfirm = $state(false);
|
||||
let deleteConfirmation = $state('');
|
||||
let deleting = $state(false);
|
||||
let deleteError = $state<string | null>(null);
|
||||
|
||||
const connectErrors: Record<string, string> = {
|
||||
already_linked: 'This GitHub account is already connected to another Wrenn account.',
|
||||
db_error: 'Something went wrong — please try again.',
|
||||
invalid_state: 'The connection attempt expired — please try again.',
|
||||
access_denied: 'GitHub access was denied.',
|
||||
exchange_failed: 'Authentication failed — please try again.'
|
||||
};
|
||||
|
||||
async function fetchMe() {
|
||||
const result = await getMe();
|
||||
if (result.ok) {
|
||||
me = result.data;
|
||||
editName = result.data.name;
|
||||
} else {
|
||||
loadError = result.error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveName() {
|
||||
if (!editName.trim() || editName.trim() === me?.name) return;
|
||||
savingName = true;
|
||||
nameError = null;
|
||||
const result = await updateName(editName.trim());
|
||||
if (result.ok) {
|
||||
auth.login(result.data);
|
||||
me = { ...me!, name: result.data.name };
|
||||
editName = result.data.name;
|
||||
toast.success('Name updated.');
|
||||
nameSaved = true;
|
||||
if (nameSavedTimer) clearTimeout(nameSavedTimer);
|
||||
nameSavedTimer = setTimeout(() => (nameSaved = false), 1500);
|
||||
} else {
|
||||
nameError = result.error;
|
||||
}
|
||||
savingName = false;
|
||||
}
|
||||
|
||||
async function handleSendPasswordReset() {
|
||||
if (!me) return;
|
||||
sendingReset = true;
|
||||
const result = await requestPasswordReset(me.email);
|
||||
sendingReset = false;
|
||||
if (result.ok) {
|
||||
toast.success('Password reset link sent to your email.');
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangePassword() {
|
||||
savingPassword = true;
|
||||
passwordError = null;
|
||||
|
||||
const body = me?.has_password
|
||||
? { current_password: currentPassword, new_password: newPassword }
|
||||
: { new_password: newPassword, confirm_password: confirmPassword };
|
||||
|
||||
const result = await changePassword(body);
|
||||
if (result.ok) {
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
const wasPasswordSet = !!me?.has_password;
|
||||
if (me) me = { ...me, has_password: true };
|
||||
toast.success(wasPasswordSet ? 'Password updated.' : 'Password added.');
|
||||
passwordSaved = true;
|
||||
if (passwordSavedTimer) clearTimeout(passwordSavedTimer);
|
||||
passwordSavedTimer = setTimeout(() => (passwordSaved = false), 1500);
|
||||
} else {
|
||||
passwordError = result.error;
|
||||
}
|
||||
savingPassword = false;
|
||||
}
|
||||
|
||||
async function handleConnectGitHub() {
|
||||
connectingGitHub = true;
|
||||
const result = await getProviderConnectURL('github');
|
||||
if (result.ok) {
|
||||
window.location.href = result.data.auth_url;
|
||||
} else {
|
||||
toast.error(result.error);
|
||||
connectingGitHub = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnectGitHub() {
|
||||
disconnectingGitHub = true;
|
||||
disconnectError = null;
|
||||
const result = await disconnectProvider('github');
|
||||
if (result.ok) {
|
||||
me = { ...me!, providers: me!.providers.filter((p) => p !== 'github') };
|
||||
showDisconnectConfirm = false;
|
||||
toast.success('GitHub disconnected.');
|
||||
} else {
|
||||
disconnectError = result.error;
|
||||
}
|
||||
disconnectingGitHub = false;
|
||||
}
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
deleting = true;
|
||||
deleteError = null;
|
||||
const result = await deleteAccount(deleteConfirmation);
|
||||
if (result.ok) {
|
||||
auth.logout();
|
||||
} else {
|
||||
deleteError = result.error;
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await fetchMe();
|
||||
|
||||
// Read OAuth callback params and clean URL immediately,
|
||||
// regardless of whether fetchMe succeeds.
|
||||
const connected = $page.url.searchParams.get('connected');
|
||||
const connectErr = $page.url.searchParams.get('connect_error');
|
||||
if (connected || connectErr) {
|
||||
goto('/dashboard/settings', { replaceState: true });
|
||||
}
|
||||
|
||||
if (connected) {
|
||||
if (me) me = { ...me, providers: [...new Set([...me.providers, connected])] };
|
||||
toast.success(`${connected.charAt(0).toUpperCase() + connected.slice(1)} connected successfully.`);
|
||||
} else if (connectErr) {
|
||||
toast.error(connectErrors[connectErr] ?? 'Failed to connect account.');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wrenn — Settings</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<Sidebar bind:collapsed />
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-hidden">
|
||||
<main class="flex-1 overflow-y-auto bg-[var(--color-bg-0)]">
|
||||
<!-- Header -->
|
||||
<div class="px-7 pt-8">
|
||||
<div>
|
||||
<h1 class="font-serif text-page text-[var(--color-text-bright)]">Settings</h1>
|
||||
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
||||
Manage your account details and security.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-6 border-b border-[var(--color-border)]"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-8">
|
||||
{#if loadError}
|
||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]" style="animation: fadeUp 0.35s ease both">
|
||||
{loadError}
|
||||
</div>
|
||||
{:else if me}
|
||||
<div class="mx-auto max-w-[560px] space-y-8">
|
||||
|
||||
<!-- ── Profile ── -->
|
||||
<section style="animation: fadeUp 0.35s ease both">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="avatar-ring flex h-14 w-14 shrink-0 items-center justify-center rounded-full border border-[var(--color-border-mid)] bg-[var(--color-bg-3)]">
|
||||
<span class="font-serif text-heading leading-none text-[var(--color-text-bright)]">{initials}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Profile</h2>
|
||||
<p class="mt-0.5 text-ui text-[var(--color-text-tertiary)]">How you appear across Wrenn.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="display-name"
|
||||
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
id="display-name"
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
disabled={savingName}
|
||||
placeholder="Your name"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">
|
||||
Email
|
||||
</span>
|
||||
<div class="rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-1)] px-3 py-2 font-mono text-ui text-[var(--color-text-secondary)]">
|
||||
{me.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if nameError}
|
||||
<p class="text-ui text-[var(--color-red)]">{nameError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={handleSaveName}
|
||||
disabled={savingName || nameSaved || !editName.trim() || editName.trim() === me.name}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:-translate-y-px active:translate-y-0 disabled:hover:translate-y-0 {nameSaved ? 'bg-[var(--color-accent-bright)]' : 'bg-[var(--color-accent)] hover:brightness-115 disabled:opacity-50'}"
|
||||
>
|
||||
{#if savingName}
|
||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
Saving…
|
||||
{:else if nameSaved}
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-draw"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
Saved
|
||||
{:else}
|
||||
Save
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="border-t border-[var(--color-border)]"></div>
|
||||
|
||||
<!-- ── Security ── -->
|
||||
<section style="animation: fadeUp 0.35s ease both; animation-delay: 60ms">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-tertiary)]"><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">
|
||||
{me.has_password ? 'Change password' : 'Add a password'}
|
||||
</h2>
|
||||
<p class="mt-0.5 text-ui text-[var(--color-text-tertiary)]">
|
||||
{me.has_password
|
||||
? 'Use a strong, unique password you don\'t use elsewhere.'
|
||||
: 'Set a password so you can sign in with your email.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-y-4">
|
||||
{#if me.has_password}
|
||||
<div>
|
||||
<label
|
||||
for="current-password"
|
||||
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
Current password
|
||||
</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
bind:value={currentPassword}
|
||||
disabled={savingPassword}
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="new-password"
|
||||
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
disabled={savingPassword}
|
||||
autocomplete="new-password"
|
||||
placeholder="Min. 8 characters"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !me.has_password}
|
||||
<div>
|
||||
<label
|
||||
for="confirm-password"
|
||||
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
Confirm password
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
disabled={savingPassword}
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_2px_var(--color-accent-glow)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if passwordError}
|
||||
<p class="text-ui text-[var(--color-red)]">{passwordError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
{#if me.has_password}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSendPasswordReset}
|
||||
disabled={sendingReset}
|
||||
class="text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)] disabled:opacity-50"
|
||||
>
|
||||
{sendingReset ? 'Sending…' : 'Forgot password?'}
|
||||
</button>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={handleChangePassword}
|
||||
disabled={savingPassword || passwordSaved || !newPassword || (me.has_password && !currentPassword) || (!me.has_password && !confirmPassword)}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] px-4 py-2 text-ui font-semibold text-white transition-all duration-150 hover:-translate-y-px active:translate-y-0 disabled:hover:translate-y-0 {passwordSaved ? 'bg-[var(--color-accent-bright)]' : 'bg-[var(--color-accent)] hover:brightness-115 disabled:opacity-50'}"
|
||||
>
|
||||
{#if savingPassword}
|
||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
Saving…
|
||||
{:else if passwordSaved}
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-draw"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
Saved
|
||||
{:else}
|
||||
{me.has_password ? 'Update password' : 'Set password'}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="border-t border-[var(--color-border)]"></div>
|
||||
|
||||
<!-- ── Connected Accounts ── -->
|
||||
<section style="animation: fadeUp 0.35s ease both; animation-delay: 120ms">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-avatar)] border border-[var(--color-border)] bg-[var(--color-bg-2)]">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-[var(--color-text-tertiary)]"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Connected accounts</h2>
|
||||
<p class="mt-0.5 text-ui text-[var(--color-text-tertiary)]">
|
||||
Sign in with a linked account instead of your password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<!-- GitHub row -->
|
||||
<div class="flex items-center justify-between rounded-[var(--radius-card)] border px-4 py-3 transition-colors duration-200 {me.providers.includes('github') ? 'border-[var(--color-accent)]/30 bg-[var(--color-accent-glow)]' : 'border-[var(--color-border)] bg-[var(--color-bg-1)]'}">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- GitHub icon -->
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" class="{me.providers.includes('github') ? 'text-[var(--color-text-bright)]' : 'text-[var(--color-text-secondary)]'}">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="text-ui font-medium text-[var(--color-text-primary)]">GitHub</div>
|
||||
{#if me.providers.includes('github')}
|
||||
<div class="flex items-center gap-1 text-meta text-[var(--color-accent)]">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="check-draw"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
Connected
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-meta text-[var(--color-text-muted)]">Not connected</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if me.providers.includes('github')}
|
||||
<button
|
||||
onclick={() => { showDisconnectConfirm = true; disconnectError = null; }}
|
||||
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-red)]/50 hover:text-[var(--color-red)]"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleConnectGitHub}
|
||||
disabled={connectingGitHub}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||
>
|
||||
{#if connectingGitHub}
|
||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
{/if}
|
||||
Connect
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="border-t border-[var(--color-border)]"></div>
|
||||
|
||||
<!-- ── Danger Zone ── -->
|
||||
<section style="animation: fadeUp 0.35s ease both; animation-delay: 180ms">
|
||||
<h2 class="font-serif text-heading text-[var(--color-red)]">Danger zone</h2>
|
||||
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||
Deleting your account is irreversible.
|
||||
</p>
|
||||
|
||||
<div class="mt-5 rounded-[var(--radius-card)] border border-[var(--color-red)]/25 border-l-[2px] border-l-[var(--color-red)]/40 bg-[var(--color-red)]/[0.03] px-4 py-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-ui font-medium text-[var(--color-text-primary)]">Delete account</div>
|
||||
<div class="mt-0.5 text-meta text-[var(--color-text-muted)]">
|
||||
Your account will be deactivated immediately and permanently removed after 15 days.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => { showDeleteConfirm = true; deleteConfirmation = ''; deleteError = null; }}
|
||||
class="shrink-0 rounded-[var(--radius-button)] border border-[var(--color-red)]/30 px-3 py-1.5 text-ui text-[var(--color-red)] transition-colors duration-150 hover:bg-[var(--color-red)]/10"
|
||||
>
|
||||
Delete account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Loading skeleton -->
|
||||
<div class="mx-auto max-w-[560px] space-y-6">
|
||||
<div class="flex items-center gap-4" style="animation: fadeUp 0.35s ease both">
|
||||
<div class="h-14 w-14 shrink-0 animate-pulse rounded-full bg-[var(--color-bg-3)]"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 w-24 animate-pulse rounded bg-[var(--color-bg-3)]"></div>
|
||||
<div class="h-3 w-40 animate-pulse rounded bg-[var(--color-bg-2)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
{#each [140, 180, 100] as h, i}
|
||||
<div style="animation: fadeUp 0.35s ease both; animation-delay: {(i + 1) * 60}ms">
|
||||
<div class="animate-pulse rounded-[var(--radius-card)] bg-[var(--color-bg-2)]" style="height: {h}px"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
<footer class="h-px shrink-0 bg-[var(--color-border)]"></footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disconnect GitHub dialog -->
|
||||
{#if showDisconnectConfirm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-fade"
|
||||
onclick={() => { if (!disconnectingGitHub) showDisconnectConfirm = false; }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape' && !disconnectingGitHub) showDisconnectConfirm = false; }}
|
||||
></div>
|
||||
<div
|
||||
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
|
||||
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
|
||||
>
|
||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Disconnect GitHub</h2>
|
||||
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||
You won't be able to sign in with GitHub. You can reconnect it later.
|
||||
</p>
|
||||
|
||||
{#if disconnectError}
|
||||
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||
{disconnectError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onclick={() => showDisconnectConfirm = false}
|
||||
disabled={disconnectingGitHub}
|
||||
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDisconnectGitHub}
|
||||
disabled={disconnectingGitHub}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if disconnectingGitHub}
|
||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
{/if}
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Delete account dialog -->
|
||||
{#if showDeleteConfirm}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/60 backdrop-fade"
|
||||
onclick={() => { if (!deleting) showDeleteConfirm = false; }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape' && !deleting) showDeleteConfirm = false; }}
|
||||
></div>
|
||||
<div
|
||||
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] p-6"
|
||||
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
|
||||
>
|
||||
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Delete account</h2>
|
||||
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||
Your account will be deactivated immediately and permanently deleted after 15 days. This cannot be undone.
|
||||
</p>
|
||||
|
||||
{#if deleteError}
|
||||
<div class="mt-4 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||
{deleteError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5">
|
||||
<label
|
||||
for="delete-confirm"
|
||||
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
Type your email to confirm
|
||||
</label>
|
||||
<input
|
||||
id="delete-confirm"
|
||||
type="email"
|
||||
bind:value={deleteConfirmation}
|
||||
disabled={deleting}
|
||||
placeholder={me?.email ?? ''}
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-[color,border-color,box-shadow] duration-150 focus:border-[var(--color-red)] focus:shadow-[0_0_0_2px_rgba(207,129,114,0.1)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onclick={() => showDeleteConfirm = false}
|
||||
disabled={deleting}
|
||||
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDeleteAccount}
|
||||
disabled={deleting || deleteConfirmation !== me?.email}
|
||||
class="flex items-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-red)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if deleting}
|
||||
<svg class="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
{/if}
|
||||
Delete account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Checkmark draw animation (mirrors CopyButton pattern) ── */
|
||||
.check-draw {
|
||||
animation: checkScale 0.3s cubic-bezier(0.25, 1, 0.5, 1) both;
|
||||
}
|
||||
:global(.check-draw polyline) {
|
||||
stroke-dasharray: 24;
|
||||
stroke-dashoffset: 24;
|
||||
animation: checkStroke 0.3s cubic-bezier(0.25, 1, 0.5, 1) 0.05s forwards;
|
||||
}
|
||||
@keyframes checkScale {
|
||||
0% { transform: scale(0.6); opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes checkStroke {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
/* ── Avatar hover ring ── */
|
||||
.avatar-ring {
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.avatar-ring:hover {
|
||||
border-color: var(--color-accent-mid);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-glow-mid);
|
||||
}
|
||||
|
||||
/* ── Dialog backdrop fade ── */
|
||||
.backdrop-fade {
|
||||
animation: backdropIn 0.2s ease both;
|
||||
}
|
||||
@keyframes backdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Respect reduced motion ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.check-draw,
|
||||
.avatar-ring,
|
||||
.backdrop-fade {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
:global(.check-draw polyline) {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
99
frontend/src/routes/forgot-password/+page.svelte
Normal file
99
frontend/src/routes/forgot-password/+page.svelte
Normal file
@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { requestPasswordReset } from '$lib/api/me';
|
||||
|
||||
let email = $state('');
|
||||
let loading = $state(false);
|
||||
let submitted = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
loading = true;
|
||||
await requestPasswordReset(email.trim().toLowerCase());
|
||||
// Always show success to avoid leaking account existence
|
||||
submitted = true;
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wrenn — Reset password</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[var(--color-bg-0)] px-4">
|
||||
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease both">
|
||||
<!-- Brand -->
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<img src="/logo.svg" alt="Wrenn" class="h-8 w-8 rounded-[var(--radius-logo)]" />
|
||||
<span class="font-brand text-[1.5rem] text-[var(--color-text-bright)]">Wrenn</span>
|
||||
</div>
|
||||
|
||||
{#if submitted}
|
||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
|
||||
<h1 class="font-serif text-heading text-[var(--color-text-bright)]">Check your email</h1>
|
||||
<p class="mt-2 text-ui text-[var(--color-text-secondary)]">
|
||||
If an account exists for <span class="font-mono text-[var(--color-text-primary)]">{email}</span>, you'll receive a reset link shortly. The link expires in 15 minutes.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="mt-6 block text-center text-ui text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
|
||||
<h1 class="font-serif text-heading text-[var(--color-text-bright)]">Reset your password</h1>
|
||||
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">
|
||||
Enter your email and we'll send you a reset link.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<p class="mt-4 text-ui text-[var(--color-red)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="mt-5 space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="you@example.com"
|
||||
autocomplete="email"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] px-3 py-2 text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email.trim()}
|
||||
class="flex w-full items-center justify-center gap-2 rounded-[var(--radius-button)] bg-[var(--color-accent)] py-2.5 text-ui font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="animate-spin" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
|
||||
Sending…
|
||||
{:else}
|
||||
Send reset link
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="mt-5 block text-center text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@ -17,10 +17,12 @@
|
||||
let mode: 'signin' | 'signup' = $state('signin');
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let name = $state('');
|
||||
let showPassword = $state(false);
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
let signupDone = $state(false);
|
||||
|
||||
const oauthErrorMessages: Record<string, string> = {
|
||||
account_deactivated: 'Your account has been deactivated — contact your administrator to regain access',
|
||||
@ -31,6 +33,11 @@
|
||||
|
||||
// Read OAuth error forwarded from /auth/github/callback
|
||||
onMount(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
goto('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
const urlErr = $page.url.searchParams.get('error');
|
||||
if (urlErr) {
|
||||
const decoded = decodeURIComponent(urlErr);
|
||||
@ -90,6 +97,8 @@
|
||||
mode = mode === 'signin' ? 'signup' : 'signin';
|
||||
error = '';
|
||||
name = '';
|
||||
confirmPassword = '';
|
||||
signupDone = false;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
@ -97,11 +106,32 @@
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
const result =
|
||||
mode === 'signin'
|
||||
? await apiLogin(email, password)
|
||||
: await apiSignup(email, password, name);
|
||||
if (mode === 'signup') {
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
error = 'Password must be at least 8 characters.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await apiSignup(email, password, name);
|
||||
loading = false;
|
||||
|
||||
if (!result.ok) {
|
||||
error = result.error;
|
||||
return;
|
||||
}
|
||||
|
||||
signupDone = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Sign in
|
||||
const result = await apiLogin(email, password);
|
||||
loading = false;
|
||||
|
||||
if (!result.ok) {
|
||||
@ -192,6 +222,25 @@
|
||||
</div>
|
||||
|
||||
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease 0.1s both">
|
||||
{#if signupDone}
|
||||
<!-- Post-signup confirmation -->
|
||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6" style="animation: fadeUp 0.3s ease both">
|
||||
<h2 class="font-serif text-display text-[var(--color-text-bright)]">Check your email</h2>
|
||||
<p class="mt-2 text-body text-[var(--color-text-secondary)]">
|
||||
We've sent an activation link to <span class="font-medium text-[var(--color-text-bright)]">{email}</span>. Click the link to activate your account.
|
||||
</p>
|
||||
<p class="mt-4 text-ui text-[var(--color-text-tertiary)]">
|
||||
The link expires in 30 minutes. If you don't see it, check your spam folder.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={switchMode}
|
||||
class="mt-6 w-full rounded-[var(--radius-button)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-4 py-3 text-body font-medium text-[var(--color-text-bright)] transition-all duration-150 hover:border-[var(--color-accent)]"
|
||||
>
|
||||
Back to sign in
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h2
|
||||
@ -283,14 +332,31 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mode === 'signup'}
|
||||
<div class="group relative">
|
||||
<div
|
||||
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]"
|
||||
>
|
||||
<IconLock size={14} />
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-3 text-body text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'signin'}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
<a
|
||||
href="/forgot-password"
|
||||
class="text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-accent-mid)]"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -327,6 +393,7 @@
|
||||
{switchAction}
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
138
frontend/src/routes/reset-password/+page.svelte
Normal file
138
frontend/src/routes/reset-password/+page.svelte
Normal file
@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { confirmPasswordReset } from '$lib/api/me';
|
||||
import { IconLock } from '$lib/components/icons';
|
||||
|
||||
let token = $state('');
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let done = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
token = $page.url.searchParams.get('token') ?? '';
|
||||
if (!token) {
|
||||
goto('/forgot-password');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = '';
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
error = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
error = 'Password must be at least 8 characters.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
const result = await confirmPasswordReset(token, newPassword);
|
||||
if (result.ok) {
|
||||
done = true;
|
||||
} else {
|
||||
error = result.error;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wrenn — Set new password</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-[var(--color-bg-0)] px-4">
|
||||
<div class="w-full max-w-[400px]" style="animation: fadeUp 0.35s ease both">
|
||||
<!-- Brand -->
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<img src="/logo.svg" alt="Wrenn" class="h-10 w-10 rounded-[var(--radius-logo)]" />
|
||||
<span class="font-brand text-page text-[var(--color-text-bright)]">Wrenn</span>
|
||||
</div>
|
||||
|
||||
{#if done}
|
||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6" style="animation: fadeUp 0.3s ease both">
|
||||
<h1 class="font-serif text-display text-[var(--color-text-bright)]">All set</h1>
|
||||
<p class="mt-1 text-ui text-[var(--color-text-secondary)]">
|
||||
Your password has been updated. Sign in to continue.
|
||||
</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="mt-6 flex w-full items-center justify-center rounded-[var(--radius-button)] bg-[var(--color-accent)] py-3 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-bg-1)] p-6">
|
||||
<h1 class="font-serif text-display text-[var(--color-text-bright)]">Set new password</h1>
|
||||
<p class="mt-1 text-ui text-[var(--color-text-tertiary)]">Must be at least 8 characters.</p>
|
||||
|
||||
{#if error}
|
||||
<p class="mt-4 text-ui text-[var(--color-red)]">{error}</p>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="mt-6 space-y-3">
|
||||
<div class="group relative">
|
||||
<div class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]">
|
||||
<IconLock size={14} />
|
||||
</div>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="New password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-3 text-body text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-all duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="group relative">
|
||||
<div class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]">
|
||||
<IconLock size={14} />
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="Confirm password"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-3 text-body text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-all duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !newPassword || !confirmPassword}
|
||||
class="!mt-5 w-full rounded-[var(--radius-button)] bg-[var(--color-accent)] py-3 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0"
|
||||
>
|
||||
{#if loading}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white"></span>
|
||||
Updating…
|
||||
</span>
|
||||
{:else}
|
||||
Set password
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href="/login"
|
||||
class="mt-5 block text-center text-meta text-[var(--color-text-tertiary)] transition-colors duration-150 hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
Back to sign in
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -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)
|
||||
|
||||
@ -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,6 +65,74 @@ 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"`
|
||||
}
|
||||
@ -62,10 +142,12 @@ type authHandler struct {
|
||||
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")
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Create default team.
|
||||
teamID := id.NewTeamID()
|
||||
if _, err := qtx.InsertTeam(ctx, db.InsertTeamParams{
|
||||
ID: teamID,
|
||||
Name: req.Name + "'s Team",
|
||||
Slug: id.NewTeamSlug(),
|
||||
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 {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to create team")
|
||||
slog.Warn("signup: failed to send activation email", "email", req.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusCreated, signupResponse{
|
||||
Message: "Account created. Please check your email to activate your account.",
|
||||
})
|
||||
}
|
||||
|
||||
// Activate handles POST /v1/auth/activate.
|
||||
func (h *authHandler) Activate(w http.ResponseWriter, r *http.Request) {
|
||||
var req activateRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if err := qtx.InsertTeamMember(ctx, db.InsertTeamMemberParams{
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
IsDefault: true,
|
||||
Role: "owner",
|
||||
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 add user to team")
|
||||
slog.Error("activate: failed to set user status", "user_id", id.FormatUserID(userID), "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to activate user")
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to commit signup")
|
||||
// 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
|
||||
}
|
||||
|
||||
token, err := auth.SignJWT(h.jwtSecret, userID, teamID, req.Email, req.Name, "owner", isFirstUser)
|
||||
isAdmin := user.IsAdmin || isFirstUser
|
||||
token, err := auth.SignJWT(h.jwtSecret, userID, team.ID, user.Email, user.Name, role, isAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := h.mailer.Send(context.Background(), req.Email, "Welcome to Wrenn", email.EmailData{
|
||||
RecipientName: req.Name,
|
||||
Message: "Welcome to Wrenn! Your account has been created and you're ready to start building with secure, isolated sandboxes.",
|
||||
Closing: "If you have any questions, feel free to reach out. We're glad to have you.",
|
||||
}); err != nil {
|
||||
slog.Warn("failed to send welcome email", "email", req.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeJSON(w, http.StatusCreated, authResponse{
|
||||
writeJSON(w, http.StatusOK, authResponse{
|
||||
Token: token,
|
||||
UserID: id.FormatUserID(userID),
|
||||
TeamID: id.FormatTeamID(teamID),
|
||||
Email: req.Email,
|
||||
Name: req.Name,
|
||||
TeamID: id.FormatTeamID(team.ID),
|
||||
Email: user.Email,
|
||||
Name: user.Name,
|
||||
})
|
||||
}
|
||||
|
||||
@ -249,23 +398,36 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
slog.Warn("login failed: account deactivated", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access")
|
||||
switch user.Status {
|
||||
case "active":
|
||||
// OK — proceed.
|
||||
case "inactive":
|
||||
slog.Warn("login failed: account not activated", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusForbidden, "account_not_activated", "please check your email and activate your account before signing in")
|
||||
return
|
||||
case "disabled":
|
||||
slog.Warn("login failed: account disabled", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusForbidden, "account_disabled", "your account has been deactivated — contact your administrator to regain access")
|
||||
return
|
||||
case "deleted":
|
||||
slog.Warn("login failed: account deleted", "email", req.Email, "ip", r.RemoteAddr)
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
|
||||
return
|
||||
default:
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password")
|
||||
return
|
||||
}
|
||||
|
||||
team, role, err := loginTeam(ctx, h.db, user.ID)
|
||||
// Ensure user has a default team (creates one on first login after activation).
|
||||
team, role, isFirstUser, err := ensureDefaultTeam(ctx, h.db, h.pool, user.ID, user.Name)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeError(w, http.StatusForbidden, "no_team", "user is not a member of any team")
|
||||
return
|
||||
}
|
||||
slog.Error("login: failed to ensure default team", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to look up team")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role, user.IsAdmin)
|
||||
isAdmin := user.IsAdmin || isFirstUser
|
||||
token, err := auth.SignJWT(h.jwtSecret, user.ID, team.ID, user.Email, user.Name, role, isAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
||||
return
|
||||
@ -355,3 +517,18 @@ func (h *authHandler) SwitchTeam(w http.ResponseWriter, r *http.Request) {
|
||||
Name: user.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func generateActivationToken() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(fmt.Sprintf("crypto/rand failed: %v", err))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func hashActivationToken(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
562
internal/api/handlers_me.go
Normal file
562
internal/api/handlers_me.go
Normal file
@ -0,0 +1,562 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"git.omukk.dev/wrenn/wrenn/internal/email"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/auth/oauth"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/db"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/id"
|
||||
"git.omukk.dev/wrenn/wrenn/pkg/service"
|
||||
)
|
||||
|
||||
const (
|
||||
passwordResetKeyPrefix = "wrenn:password_reset:"
|
||||
passwordResetTTL = 15 * time.Minute
|
||||
)
|
||||
|
||||
type meHandler struct {
|
||||
db *db.Queries
|
||||
pool *pgxpool.Pool
|
||||
rdb *redis.Client
|
||||
jwtSecret []byte
|
||||
mailer email.Mailer
|
||||
oauthRegistry *oauth.Registry
|
||||
redirectURL string
|
||||
teamSvc *service.TeamService
|
||||
}
|
||||
|
||||
func newMeHandler(
|
||||
db *db.Queries,
|
||||
pool *pgxpool.Pool,
|
||||
rdb *redis.Client,
|
||||
jwtSecret []byte,
|
||||
mailer email.Mailer,
|
||||
registry *oauth.Registry,
|
||||
redirectURL string,
|
||||
teamSvc *service.TeamService,
|
||||
) *meHandler {
|
||||
return &meHandler{
|
||||
db: db,
|
||||
pool: pool,
|
||||
rdb: rdb,
|
||||
jwtSecret: jwtSecret,
|
||||
mailer: mailer,
|
||||
oauthRegistry: registry,
|
||||
redirectURL: strings.TrimRight(redirectURL, "/"),
|
||||
teamSvc: teamSvc,
|
||||
}
|
||||
}
|
||||
|
||||
type meResponse struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
Providers []string `json:"providers"`
|
||||
}
|
||||
|
||||
type updateNameRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
ConfirmPassword string `json:"confirm_password"`
|
||||
}
|
||||
|
||||
type requestPasswordResetRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type confirmPasswordResetRequest struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
type deleteAccountRequest struct {
|
||||
Confirmation string `json:"confirmation"`
|
||||
}
|
||||
|
||||
// GetMe handles GET /v1/me.
|
||||
func (h *meHandler) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
ctx := r.Context()
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
|
||||
return
|
||||
}
|
||||
|
||||
providerNames := make([]string, 0, len(providers))
|
||||
for _, p := range providers {
|
||||
providerNames = append(providerNames, p.Provider)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, meResponse{
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
HasPassword: user.PasswordHash.Valid,
|
||||
Providers: providerNames,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateName handles PATCH /v1/me — updates the user's name and re-issues a JWT.
|
||||
func (h *meHandler) UpdateName(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
ctx := r.Context()
|
||||
|
||||
var req updateNameRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" || len(req.Name) > 100 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "name must be between 1 and 100 characters")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.UpdateUserName(ctx, db.UpdateUserNameParams{
|
||||
ID: ac.UserID,
|
||||
Name: req.Name,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to update name")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
team, role, err := loginTeam(ctx, h.db, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get team")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.SignJWT(h.jwtSecret, ac.UserID, team.ID, user.Email, req.Name, role, user.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, authResponse{
|
||||
Token: token,
|
||||
UserID: id.FormatUserID(ac.UserID),
|
||||
TeamID: id.FormatTeamID(team.ID),
|
||||
Email: user.Email,
|
||||
Name: req.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// ChangePassword handles POST /v1/me/password.
|
||||
// For users with a password: requires current_password + new_password.
|
||||
// For OAuth-only users: requires new_password + confirm_password.
|
||||
func (h *meHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
ctx := r.Context()
|
||||
|
||||
var req changePasswordRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
if user.PasswordHash.Valid {
|
||||
// Changing existing password — verify current.
|
||||
if req.CurrentPassword == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "current_password is required")
|
||||
return
|
||||
}
|
||||
if err := auth.CheckPassword(user.PasswordHash.String, req.CurrentPassword); err != nil {
|
||||
writeError(w, http.StatusUnauthorized, "wrong_password", "current password is incorrect")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// OAuth user adding a password — confirm must match.
|
||||
if req.ConfirmPassword == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "confirm_password is required")
|
||||
return
|
||||
}
|
||||
if req.NewPassword != req.ConfirmPassword {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "passwords do not match")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.NewPassword) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.UpdateUserPassword(ctx, db.UpdateUserPasswordParams{
|
||||
ID: ac.UserID,
|
||||
PasswordHash: pgtype.Text{String: hash, Valid: true},
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
|
||||
return
|
||||
}
|
||||
|
||||
isAdding := !user.PasswordHash.Valid
|
||||
go func() {
|
||||
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
subject, message := "Your Wrenn password was changed", "Your account password was successfully updated. If you did not make this change, reset your password immediately."
|
||||
if isAdding {
|
||||
subject = "Password added to your Wrenn account"
|
||||
message = "A password has been added to your Wrenn account. You can now sign in with your email and password in addition to any connected OAuth providers."
|
||||
}
|
||||
if err := h.mailer.Send(sendCtx, user.Email, subject, email.EmailData{
|
||||
RecipientName: user.Name,
|
||||
Message: message,
|
||||
Closing: "If you didn't make this change, contact support immediately.",
|
||||
}); err != nil {
|
||||
slog.Warn("change password: failed to send notification", "email", user.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RequestPasswordReset handles POST /v1/me/password/reset (unauthenticated).
|
||||
// Always returns 200 to avoid leaking account existence.
|
||||
func (h *meHandler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var req requestPasswordResetRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
|
||||
if req.Email == "" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
user, err := h.db.GetUserByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
// Don't leak whether the email exists.
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
if user.Status != "active" {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
rawToken := generateResetToken()
|
||||
tokenHash := hashResetToken(rawToken)
|
||||
redisKey := passwordResetKeyPrefix + tokenHash
|
||||
|
||||
if err := h.rdb.Set(ctx, redisKey, id.FormatUserID(user.ID), passwordResetTTL).Err(); err != nil {
|
||||
slog.Error("password reset: failed to store token in redis", "error", err)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
resetURL := h.redirectURL + "/reset-password?token=" + rawToken
|
||||
go func() {
|
||||
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := h.mailer.Send(sendCtx, user.Email, "Reset your Wrenn password", email.EmailData{
|
||||
RecipientName: user.Name,
|
||||
Message: "We received a request to reset your password. Click the button below to set a new password. This link expires in 15 minutes.",
|
||||
Button: &email.Button{Text: "Reset Password", URL: resetURL},
|
||||
Closing: "If you didn't request a password reset, you can safely ignore this email.",
|
||||
}); err != nil {
|
||||
slog.Error("password reset: failed to send email", "email", user.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ConfirmPasswordReset handles POST /v1/me/password/reset/confirm (unauthenticated).
|
||||
func (h *meHandler) ConfirmPasswordReset(w http.ResponseWriter, r *http.Request) {
|
||||
var req confirmPasswordResetRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Token == "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "token is required")
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "password must be at least 8 characters")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
tokenHash := hashResetToken(req.Token)
|
||||
redisKey := passwordResetKeyPrefix + tokenHash
|
||||
|
||||
// GetDel atomically retrieves and removes the token in a single round-trip,
|
||||
// preventing concurrent requests from both consuming the same token.
|
||||
userIDStr, err := h.rdb.GetDel(ctx, redisKey).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_token", "reset token is invalid or has expired")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to verify token")
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := id.ParseUserID(userIDStr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "invalid stored user ID")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.UpdateUserPassword(ctx, db.UpdateUserPasswordParams{
|
||||
ID: userID,
|
||||
PasswordHash: pgtype.Text{String: hash, Valid: true},
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to update password")
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := h.mailer.Send(sendCtx, user.Email, "Your Wrenn password was reset", email.EmailData{
|
||||
RecipientName: user.Name,
|
||||
Message: "Your password has been successfully reset. You can now sign in with your new password.",
|
||||
Closing: "If you didn't request this change, contact support immediately.",
|
||||
}); err != nil {
|
||||
slog.Warn("confirm password reset: failed to send notification", "email", user.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ConnectProvider handles GET /v1/me/providers/{provider}/connect.
|
||||
// Sets OAuth state + link cookies and returns the provider auth URL.
|
||||
func (h *meHandler) ConnectProvider(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
provider := chi.URLParam(r, "provider")
|
||||
|
||||
p, ok := h.oauthRegistry.Get(provider)
|
||||
if !ok {
|
||||
writeError(w, http.StatusNotFound, "provider_not_found", "unsupported OAuth provider")
|
||||
return
|
||||
}
|
||||
|
||||
state, err := generateState()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate state")
|
||||
return
|
||||
}
|
||||
|
||||
mac := computeHMAC(h.jwtSecret, state)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oauth_state",
|
||||
Value: state + ":" + mac,
|
||||
Path: "/",
|
||||
MaxAge: 600,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: isSecure(r),
|
||||
})
|
||||
|
||||
userIDStr := id.FormatUserID(ac.UserID)
|
||||
linkMac := computeHMAC(h.jwtSecret, userIDStr)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oauth_link_user_id",
|
||||
Value: userIDStr + ":" + linkMac,
|
||||
Path: "/",
|
||||
MaxAge: 600,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: isSecure(r),
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"auth_url": p.AuthCodeURL(state)})
|
||||
}
|
||||
|
||||
// DisconnectProvider handles DELETE /v1/me/providers/{provider}.
|
||||
func (h *meHandler) DisconnectProvider(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
provider := chi.URLParam(r, "provider")
|
||||
ctx := r.Context()
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
providers, err := h.db.GetOAuthProvidersByUserID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get providers")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the user will still have at least one login method after disconnecting.
|
||||
if !user.PasswordHash.Valid && len(providers) <= 1 {
|
||||
writeError(w, http.StatusBadRequest, "last_login_method", "cannot disconnect your only login method — add a password first")
|
||||
return
|
||||
}
|
||||
|
||||
// Check the provider is actually linked to this user.
|
||||
found := false
|
||||
for _, p := range providers {
|
||||
if p.Provider == provider {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
writeError(w, http.StatusNotFound, "not_found", "provider not connected")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.DeleteOAuthProvider(ctx, db.DeleteOAuthProviderParams{
|
||||
UserID: ac.UserID,
|
||||
Provider: provider,
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to disconnect provider")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// DeleteAccount handles DELETE /v1/me — soft-deletes the user's account.
|
||||
func (h *meHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
ac := auth.MustFromContext(r.Context())
|
||||
ctx := r.Context()
|
||||
|
||||
var req deleteAccountRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUserByID(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to get user")
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(strings.TrimSpace(req.Confirmation), user.Email) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "confirmation does not match your email address")
|
||||
return
|
||||
}
|
||||
|
||||
teamsBlocking, err := h.db.CountUserOwnedTeamsWithOtherMembers(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to check team ownership")
|
||||
return
|
||||
}
|
||||
if teamsBlocking > 0 {
|
||||
writeError(w, http.StatusConflict, "owns_team_with_members",
|
||||
fmt.Sprintf("you own %d team(s) with other members — transfer ownership or remove members before deleting your account", teamsBlocking))
|
||||
return
|
||||
}
|
||||
|
||||
// Delete all teams the user solely owns (no other members).
|
||||
soleTeams, err := h.db.ListSoleOwnedTeams(ctx, ac.UserID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to list owned teams")
|
||||
return
|
||||
}
|
||||
for _, teamID := range soleTeams {
|
||||
if err := h.teamSvc.DeleteTeamInternal(ctx, teamID); err != nil {
|
||||
slog.Warn("account delete: failed to delete sole-owned team",
|
||||
"team_id", id.FormatTeamID(teamID), "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.db.SoftDeleteUser(ctx, ac.UserID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete account")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("account soft-deleted", "user_id", id.FormatUserID(ac.UserID), "email", user.Email)
|
||||
|
||||
go func() {
|
||||
sendCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := h.mailer.Send(sendCtx, user.Email, "Your Wrenn account has been deleted", email.EmailData{
|
||||
RecipientName: user.Name,
|
||||
Message: "Your Wrenn account has been deactivated and is scheduled for permanent deletion in 15 days. If this was a mistake, contact support before then to recover your account.",
|
||||
Closing: "Thank you for using Wrenn.",
|
||||
}); err != nil {
|
||||
slog.Warn("delete account: failed to send notification", "email", user.Email, "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func generateResetToken() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(fmt.Sprintf("crypto/rand failed: %v", err))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func hashResetToken(raw string) string {
|
||||
h := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@ -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.
|
||||
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
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
} 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
|
||||
}
|
||||
|
||||
@ -80,7 +80,7 @@ func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
TeamsJoined int32 `json:"teams_joined"`
|
||||
TeamsOwned int32 `json:"teams_owned"`
|
||||
@ -93,7 +93,7 @@ func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
Email: u.Email,
|
||||
Name: u.Name,
|
||||
IsAdmin: u.IsAdmin,
|
||||
IsActive: u.IsActive,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||
TeamsJoined: u.TeamsJoined,
|
||||
TeamsOwned: u.TeamsOwned,
|
||||
@ -135,9 +135,14 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.SetUserActive(r.Context(), userID, req.Active); err != nil {
|
||||
status, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, status, code, msg)
|
||||
newStatus := "active"
|
||||
if !req.Active {
|
||||
newStatus = "disabled"
|
||||
}
|
||||
|
||||
if err := h.svc.SetUserStatus(r.Context(), userID, newStatus); err != nil {
|
||||
httpStatus, code, msg := serviceErrToHTTP(err)
|
||||
writeError(w, httpStatus, code, msg)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -71,7 +71,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "user not found")
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
if user.Status != "active" {
|
||||
writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access")
|
||||
return
|
||||
}
|
||||
|
||||
@ -50,7 +50,7 @@ func requireJWT(secret []byte, queries *db.Queries) func(http.Handler) http.Hand
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized", "user not found")
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
if user.Status != "active" {
|
||||
writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access")
|
||||
return
|
||||
}
|
||||
|
||||
@ -16,6 +16,10 @@ paths:
|
||||
summary: Create a new account
|
||||
operationId: signup
|
||||
tags: [auth]
|
||||
description: |
|
||||
Creates an inactive user account and sends an activation email.
|
||||
The user must activate their account within 30 minutes.
|
||||
Does not return a JWT — the user must activate first, then sign in.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@ -24,11 +28,11 @@ paths:
|
||||
$ref: "#/components/schemas/SignupRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Account created
|
||||
description: Account created, activation email sent
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthResponse"
|
||||
$ref: "#/components/schemas/SignupResponse"
|
||||
"400":
|
||||
description: Invalid request (bad email, short password)
|
||||
content:
|
||||
@ -36,7 +40,39 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"409":
|
||||
description: Email already registered
|
||||
description: Email already registered or signup cooldown active
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/v1/auth/activate:
|
||||
post:
|
||||
summary: Activate account via email token
|
||||
operationId: activate
|
||||
tags: [auth]
|
||||
description: |
|
||||
Consumes the activation token sent via email and activates the user account.
|
||||
Creates a default team and returns a JWT to log the user in.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [token]
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: Account activated, JWT issued
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AuthResponse"
|
||||
"400":
|
||||
description: Invalid or expired token
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -28,12 +28,21 @@
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 16px;">
|
||||
|
||||
<!-- Logo -->
|
||||
<!-- Logo + Wordmark -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px;">
|
||||
<tr>
|
||||
<td align="left" style="padding-bottom: 24px;">
|
||||
<td align="left" style="padding-bottom: 32px;">
|
||||
<a href="https://wrenn.dev" style="text-decoration: none;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
<img src="https://wrenn.dev/logo.png" alt="Wrenn" width="36" height="36" style="display: block; border-radius: 6px;">
|
||||
</td>
|
||||
<td style="vertical-align: middle; padding-left: 10px;">
|
||||
<span style="font-family: 'Alice', Georgia, 'Times New Roman', serif; font-size: 22px; color: #1a1917; letter-spacing: 0.01em;">Wrenn</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@ -42,21 +51,21 @@
|
||||
<!-- Card -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px; background-color: #ffffff; border: 1px solid #e5e4e0; border-radius: 8px;">
|
||||
<tr>
|
||||
<td style="padding: 40px 44px;">
|
||||
<td style="padding: 44px 48px;">
|
||||
|
||||
<!-- Greeting -->
|
||||
<p style="margin: 0 0 20px 0; font-size: 15px; line-height: 1.6; color: #3a3835;">
|
||||
<p style="margin: 0 0 8px 0; font-size: 15px; line-height: 1.6; color: #3a3835;">
|
||||
Hello{{if .RecipientName}} {{.RecipientName}}{{end}},
|
||||
</p>
|
||||
|
||||
<!-- Message -->
|
||||
<p style="margin: 0 0 24px 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
|
||||
<p style="margin: 0 0 36px 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
|
||||
{{.Message}}
|
||||
</p>
|
||||
|
||||
<!-- Button -->
|
||||
{{if .Button}}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 28px 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 36px 0;">
|
||||
<tr>
|
||||
<td align="center" style="background-color: #5e8c58; border-radius: 5px;">
|
||||
<!--[if mso]>
|
||||
@ -66,7 +75,7 @@
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="{{.Button.URL}}" target="_blank" style="display: inline-block; padding: 12px 28px; font-size: 14px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 5px; background-color: #5e8c58;">
|
||||
<a href="{{.Button.URL}}" target="_blank" style="display: inline-block; padding: 12px 32px; font-size: 14px; font-weight: 600; color: #ffffff; text-decoration: none; border-radius: 5px; background-color: #5e8c58;">
|
||||
{{.Button.Text}}
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
@ -74,7 +83,7 @@
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="margin: 0 0 24px 0; font-size: 13px; line-height: 1.6; color: #9b9790;">
|
||||
<p style="margin: 0 0 12px 0; font-size: 12px; line-height: 1.5; color: #b5b0a8;">
|
||||
If the button doesn't work, copy and paste this URL into your browser:<br>
|
||||
<a href="{{.Button.URL}}" style="color: #5e8c58; word-break: break-all;">{{.Button.URL}}</a>
|
||||
</p>
|
||||
@ -82,7 +91,7 @@
|
||||
|
||||
<!-- Closing -->
|
||||
{{if .Closing}}
|
||||
<p style="margin: 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
|
||||
<p style="margin: {{if .Button}}20px{{else}}0{{end}} 0 0 0; font-size: 15px; line-height: 1.7; color: #3a3835;">
|
||||
{{.Closing}}
|
||||
</p>
|
||||
{{end}}
|
||||
@ -94,7 +103,7 @@
|
||||
<!-- Footer -->
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="560" style="max-width: 560px;">
|
||||
<tr>
|
||||
<td style="padding: 24px 0; text-align: center;">
|
||||
<td style="padding: 32px 0 16px 0; text-align: center;">
|
||||
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #9b9790;">
|
||||
This is a transactional email from <a href="https://wrenn.dev" style="color: #5e8c58; text-decoration: none;">Wrenn</a>.
|
||||
</p>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -200,8 +200,8 @@ type User struct {
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type UsersTeam struct {
|
||||
|
||||
@ -11,6 +11,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)
|
||||
|
||||
@ -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
|
||||
`
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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{
|
||||
// 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,
|
||||
IsActive: active,
|
||||
Status: status,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("set user active: %w", err)
|
||||
return fmt.Errorf("set user status: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -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}"
|
||||
Reference in New Issue
Block a user