diff --git a/db/migrations/20260414213729_add_user_active_deleted.sql b/db/migrations/20260414213729_add_user_active_deleted.sql new file mode 100644 index 0000000..b54b588 --- /dev/null +++ b/db/migrations/20260414213729_add_user_active_deleted.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ; + +-- +goose Down +ALTER TABLE users DROP COLUMN deleted_at; +ALTER TABLE users DROP COLUMN is_active; diff --git a/db/queries/teams.sql b/db/queries/teams.sql index 2117e95..d94341d 100644 --- a/db/queries/teams.sql +++ b/db/queries/teams.sql @@ -53,3 +53,28 @@ UPDATE users_teams SET role = $3 WHERE team_id = $1 AND user_id = $2; -- name: DeleteTeamMember :exec DELETE FROM users_teams WHERE team_id = $1 AND user_id = $2; + +-- name: ListTeamsAdmin :many +SELECT + t.id, + t.name, + t.slug, + t.is_byoc, + t.created_at, + t.deleted_at, + (SELECT COUNT(*) FROM users_teams ut WHERE ut.team_id = t.id)::int AS member_count, + COALESCE(owner_u.name, '') AS owner_name, + COALESCE(owner_u.email, '') AS owner_email, + (SELECT COUNT(*) FROM sandboxes s WHERE s.team_id = t.id AND s.status IN ('running', 'paused', 'starting'))::int AS active_sandbox_count, + (SELECT COUNT(*) FROM channels c WHERE c.team_id = t.id)::int AS channel_count +FROM teams t +LEFT JOIN users_teams owner_ut ON owner_ut.team_id = t.id AND owner_ut.role = 'owner' +LEFT JOIN users owner_u ON owner_u.id = owner_ut.user_id +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: CountTeamsAdmin :one +SELECT COUNT(*)::int AS total +FROM teams +WHERE id != '00000000-0000-0000-0000-000000000000'; diff --git a/db/queries/users.sql b/db/queries/users.sql index fe0e1fd..bd7d85a 100644 --- a/db/queries/users.sql +++ b/db/queries/users.sql @@ -43,3 +43,26 @@ SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10; -- name: UpdateUserName :exec UPDATE users SET name = $2, updated_at = NOW() WHERE id = $1; + +-- name: ListUsersAdmin :many +SELECT + u.id, + u.email, + u.name, + u.is_admin, + u.is_active, + 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 +FROM users u +WHERE u.deleted_at IS NULL +ORDER BY u.created_at DESC +LIMIT $1 OFFSET $2; + +-- name: CountUsersAdmin :one +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; diff --git a/frontend/src/lib/api/admin-users.ts b/frontend/src/lib/api/admin-users.ts new file mode 100644 index 0000000..a8cfa19 --- /dev/null +++ b/frontend/src/lib/api/admin-users.ts @@ -0,0 +1,28 @@ +import { apiFetch, type ApiResult } from '$lib/api/client'; + +export type AdminUser = { + id: string; + email: string; + name: string; + is_admin: boolean; + is_active: boolean; + created_at: string; + teams_joined: number; + teams_owned: number; +}; + +export type AdminUsersResponse = { + users: AdminUser[]; + total: number; + page: number; + per_page: number; + total_pages: number; +}; + +export async function listAdminUsers(page: number = 1): Promise> { + return apiFetch('GET', `/api/v1/admin/users?page=${page}`); +} + +export async function setUserActive(id: string, active: boolean): Promise> { + return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active }); +} diff --git a/frontend/src/lib/api/team.ts b/frontend/src/lib/api/team.ts index 0ffc4ed..2cebb8e 100644 --- a/frontend/src/lib/api/team.ts +++ b/frontend/src/lib/api/team.ts @@ -83,3 +83,39 @@ export async function leaveTeam(id: string): Promise> { export async function searchUsers(email: string): Promise> { return apiFetch('GET', `/api/v1/users/search?email=${encodeURIComponent(email)}`); } + +// Admin team types and API functions + +export type AdminTeam = { + id: string; + name: string; + slug: string; + is_byoc: boolean; + created_at: string; + deleted_at: string | null; + member_count: number; + owner_name: string; + owner_email: string; + active_sandbox_count: number; + channel_count: number; +}; + +export type AdminTeamsResponse = { + teams: AdminTeam[]; + total: number; + page: number; + per_page: number; + total_pages: number; +}; + +export async function listAdminTeams(page: number = 1): Promise> { + return apiFetch('GET', `/api/v1/admin/teams?page=${page}`); +} + +export async function adminSetBYOC(id: string, enabled: boolean): Promise> { + return apiFetch('PUT', `/api/v1/admin/teams/${id}/byoc`, { enabled }); +} + +export async function adminDeleteTeam(id: string): Promise> { + return apiFetch('DELETE', `/api/v1/admin/teams/${id}`); +} diff --git a/frontend/src/lib/components/AdminSidebar.svelte b/frontend/src/lib/components/AdminSidebar.svelte index 2b55db6..e7421b0 100644 --- a/frontend/src/lib/components/AdminSidebar.svelte +++ b/frontend/src/lib/components/AdminSidebar.svelte @@ -11,7 +11,9 @@ IconBell, IconDocs, IconChevron, - IconShield + IconShield, + IconMembers, + IconUser } from './icons'; let { collapsed = $bindable(false) }: { collapsed: boolean } = $props(); @@ -23,6 +25,11 @@ }; const managementItems: NavItem[] = [ + { label: 'Users', icon: IconUser, href: '/admin/users' }, + { label: 'Teams', icon: IconMembers, href: '/admin/teams' } + ]; + + const platformItems: NavItem[] = [ { label: 'Templates', icon: IconBox, href: '/admin/templates' }, { label: 'Capsules', icon: IconMonitor, href: '/admin/capsules' }, { label: 'Hosts', icon: IconServer, href: '/admin/hosts' } @@ -98,43 +105,8 @@ @@ -186,3 +158,43 @@ + +{#snippet navSection(title: string, items: NavItem[])} +
+ {#if !collapsed} +
+ {title} +
+ {:else} +
+ {/if} + {#each items as item} + {#if isActive(item.href)} + + {#if !collapsed} +
+ {/if} + + {#if !collapsed} + {item.label} + {/if} +
+ {:else} + + + {#if !collapsed} + {item.label} + {/if} + + {/if} + {/each} +
+{/snippet} diff --git a/frontend/src/routes/admin/teams/+page.svelte b/frontend/src/routes/admin/teams/+page.svelte new file mode 100644 index 0000000..7c1394b --- /dev/null +++ b/frontend/src/routes/admin/teams/+page.svelte @@ -0,0 +1,509 @@ + + + + Wrenn Admin — Teams + + + + +
+ + +
+ +
+
+ +
+
+

+ Teams +

+

+ All registered teams, BYOC status, and active capsules. +

+
+
+ + + {#if !loading && !error} +
+
+ {totalTeams} + team{totalTeams !== 1 ? 's' : ''} +
+ {#if byocCount > 0} +
+ {byocCount} + BYOC +
+ {/if} + {#if totalActiveSandboxes > 0} +
+ + + + + {totalActiveSandboxes} + active +
+ {/if} +
+ {/if} +
+ + +
+ {#if error} +
+ + + + {error}. Try refreshing the page. +
+ {/if} + + +
+ +
+
Name
+
Members
+
Owner
+
BYOC
+
Capsules
+
Channels
+
Created
+
Actions
+
+ + {#if loading && teams.length === 0} +
+
+ + + + Loading teams... +
+
+ {:else if teams.length === 0} +
+
+
+
+ + + +
+
+

+ No teams yet +

+

+ Teams are created when users sign up. +

+
+ {:else} + {#each teams as team, i (team.id)} + {@const isDeleted = !!team.deleted_at} +
+ + {#if !isDeleted} +
+ {/if} + + +
+
+ {team.name} + {#if isDeleted} + + Deleted + + {/if} +
+ {team.slug} +
+ + +
+ {team.member_count} +
+ + +
+ {#if team.owner_name || team.owner_email} + {team.owner_name || '\u2014'} + {team.owner_email} + {:else} + + {/if} +
+ + +
+ {#if team.is_byoc} + + Enabled + + {:else if !isDeleted} + + {:else} + + {/if} +
+ + +
+ {#if team.active_sandbox_count > 0} + + + + + + {team.active_sandbox_count} + + {:else} + 0 + {/if} +
+ + +
+ {team.channel_count} +
+ + +
+ {formatDate(team.created_at)} +
+ + +
+ {#if !isDeleted} + + {/if} +
+
+ {/each} + {/if} +
+ + + {#if totalPages > 1} +
+ + Page {currentPage} of {totalPages} + +
+ + +
+
+ {/if} +
+ + +
+
+ + + + + All systems operational +
+
+
+
+ + +{#if byocTarget} +
+ +
{ if (!enablingByoc) byocTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !enablingByoc) byocTarget = null; }} + >
+
+
+

+ Enable BYOC +

+

+ Allow {byocTarget.name} to register and run capsules on their own hosts. +

+ +
+ + + + + BYOC cannot be disabled once enabled. + +
+ + {#if byocError} +
+ {byocError} +
+ {/if} + +
+ + +
+
+
+
+{/if} + + +{#if deleteTarget} +
+ +
{ if (!deleting) deleteTarget = null; }} + onkeydown={(e) => { if (e.key === 'Escape' && !deleting) deleteTarget = null; }} + >
+
+
+

+ Delete Team +

+

+ Remove {deleteTarget.name} and stop all its running capsules. Members will lose access immediately. +

+ + {#if deleteTarget.active_sandbox_count > 0} +
+ + + + + {deleteTarget.active_sandbox_count} active capsule{deleteTarget.active_sandbox_count !== 1 ? 's' : ''} will be destroyed immediately. + +
+ {/if} + + {#if deleteError} +
+ {deleteError} +
+ {/if} + +
+ + +
+
+
+
+{/if} diff --git a/frontend/src/routes/admin/users/+page.svelte b/frontend/src/routes/admin/users/+page.svelte new file mode 100644 index 0000000..07432ec --- /dev/null +++ b/frontend/src/routes/admin/users/+page.svelte @@ -0,0 +1,305 @@ + + + + Wrenn Admin — Users + + + + +
+ + +
+ +
+
+ +
+
+

+ Users +

+

+ All registered users, team memberships, and account status. +

+
+
+ + + {#if !loading && !error} +
+
+ {totalUsers} + user{totalUsers !== 1 ? 's' : ''} +
+
+ {/if} +
+ + +
+ {#if error} +
+ + + + {error}. Try refreshing the page. +
+ {/if} + + +
+ +
+
Name
+
Email
+
Teams
+
Owned
+
Role
+
Joined
+
Status
+
+ + {#if loading && users.length === 0} +
+
+ + + + Loading users... +
+
+ {:else if users.length === 0} +
+
+
+
+ + + +
+
+

+ No users yet +

+

+ Users appear here when they sign up. +

+
+ {:else} + {#each users as user, i (user.id)} +
+ + {#if user.is_active} +
+ {/if} + + +
+
+ {user.name || '\u2014'} + {#if user.is_admin} + + Admin + + {/if} +
+ {user.id} +
+ + +
+ {user.email} +
+ + +
+ {user.teams_joined} +
+ + +
+ {user.teams_owned} +
+ + +
+ {user.is_admin ? 'Admin' : 'User'} +
+ + +
+ {formatDate(user.created_at)} +
+ + +
+ +
+
+ {/each} + {/if} +
+ + + {#if totalPages > 1} +
+ + Page {currentPage} of {totalPages} + +
+ + +
+
+ {/if} +
+ + +
+
+ + + + + All systems operational +
+
+
+
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 116b3a0..2deb4dd 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -22,10 +22,20 @@ let error = $state(''); let loading = $state(false); + const oauthErrorMessages: Record = { + account_deactivated: 'Your account has been deactivated — contact your administrator to regain access', + access_denied: 'Access was denied by the provider', + email_taken: 'An account with this email already exists', + exchange_failed: 'Authentication failed — please try again', + }; + // Read OAuth error forwarded from /auth/github/callback onMount(() => { const urlErr = $page.url.searchParams.get('error'); - if (urlErr) error = decodeURIComponent(urlErr); + if (urlErr) { + const decoded = decodeURIComponent(urlErr); + error = oauthErrorMessages[decoded] ?? decoded; + } }); // Mouse-reactive glow — moves opposite to cursor with viscous drag diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 9a22fca..8502554 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -237,6 +237,12 @@ 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") + return + } + team, role, err := loginTeam(ctx, h.db, user.ID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go index 9209e86..4bf8ca7 100644 --- a/internal/api/handlers_oauth.go +++ b/internal/api/handlers_oauth.go @@ -150,6 +150,11 @@ 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) + redirectWithError(w, r, redirectBase, "account_deactivated") + return + } team, role, err := loginTeam(ctx, h.db, user.ID) if err != nil { slog.Error("oauth login: failed to get team", "error", err) @@ -301,6 +306,11 @@ 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) + redirectWithError(w, r, redirectBase, "account_deactivated") + return + } team, role, err := loginTeam(ctx, h.db, user.ID) if err != nil { slog.Error("oauth: retry login: failed to get team", "error", err) diff --git a/internal/api/handlers_team.go b/internal/api/handlers_team.go index ed23134..1c26681 100644 --- a/internal/api/handlers_team.go +++ b/internal/api/handlers_team.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "log/slog" "net/http" "strings" @@ -388,3 +389,87 @@ func (h *teamHandler) SetBYOC(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +// AdminListTeams handles GET /v1/admin/teams?page=1 +// Returns a paginated list of all teams with member counts, owner info, and active sandbox counts. +func (h *teamHandler) AdminListTeams(w http.ResponseWriter, r *http.Request) { + page := 1 + if p := r.URL.Query().Get("page"); p != "" { + if _, err := fmt.Sscanf(p, "%d", &page); err != nil || page < 1 { + page = 1 + } + } + const perPage = 100 + offset := int32((page - 1) * perPage) + + teams, total, err := h.svc.AdminListTeams(r.Context(), perPage, offset) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + type adminTeamResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + IsByoc bool `json:"is_byoc"` + CreatedAt string `json:"created_at"` + DeletedAt *string `json:"deleted_at"` + MemberCount int32 `json:"member_count"` + OwnerName string `json:"owner_name"` + OwnerEmail string `json:"owner_email"` + ActiveSandboxCount int32 `json:"active_sandbox_count"` + ChannelCount int32 `json:"channel_count"` + } + + resp := make([]adminTeamResponse, len(teams)) + for i, t := range teams { + r := adminTeamResponse{ + ID: id.FormatTeamID(t.ID), + Name: t.Name, + Slug: t.Slug, + IsByoc: t.IsByoc, + CreatedAt: t.CreatedAt.Format(time.RFC3339), + MemberCount: t.MemberCount, + OwnerName: t.OwnerName, + OwnerEmail: t.OwnerEmail, + ActiveSandboxCount: t.ActiveSandboxCount, + ChannelCount: t.ChannelCount, + } + if t.DeletedAt != nil { + s := t.DeletedAt.Format(time.RFC3339) + r.DeletedAt = &s + } + resp[i] = r + } + + totalPages := (total + perPage - 1) / perPage + writeJSON(w, http.StatusOK, map[string]any{ + "teams": resp, + "total": total, + "page": page, + "per_page": perPage, + "total_pages": totalPages, + }) +} + +// AdminDeleteTeam handles DELETE /v1/admin/teams/{id} +// Soft-deletes a team and destroys all its active sandboxes. +func (h *teamHandler) AdminDeleteTeam(w http.ResponseWriter, r *http.Request) { + teamIDStr := chi.URLParam(r, "id") + + teamID, err := id.ParseTeamID(teamIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid team ID") + return + } + + if err := h.svc.AdminDeleteTeam(r.Context(), teamID); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handlers_users.go b/internal/api/handlers_users.go index 5f9ef6a..ab3098c 100644 --- a/internal/api/handlers_users.go +++ b/internal/api/handlers_users.go @@ -1,22 +1,27 @@ package api import ( + "fmt" "net/http" "strings" + "time" + "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgtype" "git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/internal/id" + "git.omukk.dev/wrenn/wrenn/internal/service" ) type usersHandler struct { - db *db.Queries + db *db.Queries + svc *service.UserService } -func newUsersHandler(db *db.Queries) *usersHandler { - return &usersHandler{db: db} +func newUsersHandler(db *db.Queries, svc *service.UserService) *usersHandler { + return &usersHandler{db: db, svc: svc} } // Search handles GET /v1/users/search?email= @@ -50,3 +55,91 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, resp) } + +// AdminListUsers handles GET /v1/admin/users?page=1 +// Returns a paginated list of all users with team counts. +func (h *usersHandler) AdminListUsers(w http.ResponseWriter, r *http.Request) { + page := 1 + if p := r.URL.Query().Get("page"); p != "" { + if _, err := fmt.Sscanf(p, "%d", &page); err != nil || page < 1 { + page = 1 + } + } + const perPage = 100 + offset := int32((page - 1) * perPage) + + users, total, err := h.svc.AdminListUsers(r.Context(), perPage, offset) + if err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + type adminUserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + IsAdmin bool `json:"is_admin"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + TeamsJoined int32 `json:"teams_joined"` + TeamsOwned int32 `json:"teams_owned"` + } + + resp := make([]adminUserResponse, len(users)) + for i, u := range users { + resp[i] = adminUserResponse{ + ID: id.FormatUserID(u.ID), + Email: u.Email, + Name: u.Name, + IsAdmin: u.IsAdmin, + IsActive: u.IsActive, + CreatedAt: u.CreatedAt.Format(time.RFC3339), + TeamsJoined: u.TeamsJoined, + TeamsOwned: u.TeamsOwned, + } + } + + totalPages := (total + perPage - 1) / perPage + writeJSON(w, http.StatusOK, map[string]any{ + "users": resp, + "total": total, + "page": page, + "per_page": perPage, + "total_pages": totalPages, + }) +} + +// SetUserActive handles PUT /v1/admin/users/{id}/active +// Enables or disables a user account. Admins cannot deactivate themselves. +func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) { + ac := auth.MustFromContext(r.Context()) + userIDStr := chi.URLParam(r, "id") + + userID, err := id.ParseUserID(userIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid user ID") + return + } + + var req struct { + Active bool `json:"active"` + } + if err := decodeJSON(r, &req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body") + return + } + + if ac.UserID == userID && !req.Active { + writeError(w, http.StatusBadRequest, "invalid_request", "cannot deactivate your own account") + return + } + + if err := h.svc.SetUserActive(r.Context(), userID, req.Active); err != nil { + status, code, msg := serviceErrToHTTP(err) + writeError(w, status, code, msg) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go index 5c00c25..2c5e192 100644 --- a/internal/api/middleware_auth.go +++ b/internal/api/middleware_auth.go @@ -64,6 +64,18 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler return } + // Verify user is still active in the database. + user, err := queries.GetUserByID(r.Context(), userID) + if err != nil { + slog.Warn("jwt auth: failed to look up user", "user_id", claims.Subject, "error", err) + writeError(w, http.StatusUnauthorized, "unauthorized", "user not found") + return + } + if !user.IsActive { + writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access") + return + } + ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{ TeamID: teamID, UserID: userID, diff --git a/internal/api/middleware_jwt.go b/internal/api/middleware_jwt.go index 421f5fa..a4f311a 100644 --- a/internal/api/middleware_jwt.go +++ b/internal/api/middleware_jwt.go @@ -1,16 +1,19 @@ package api import ( + "log/slog" "net/http" "strings" "git.omukk.dev/wrenn/wrenn/internal/auth" + "git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/internal/id" ) // requireJWT validates a JWT from the Authorization: Bearer header or the // ?token= query parameter (for WebSocket connections that cannot send headers). -func requireJWT(secret []byte) func(http.Handler) http.Handler { +// It also verifies the user is still active in the database. +func requireJWT(secret []byte, queries *db.Queries) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var tokenStr string @@ -40,6 +43,18 @@ func requireJWT(secret []byte) func(http.Handler) http.Handler { return } + // Verify user is still active in the database. + user, err := queries.GetUserByID(r.Context(), userID) + if err != nil { + slog.Warn("jwt auth: failed to look up user", "user_id", claims.Subject, "error", err) + writeError(w, http.StatusUnauthorized, "unauthorized", "user not found") + return + } + if !user.IsActive { + writeError(w, http.StatusForbidden, "account_deactivated", "your account has been deactivated — contact your administrator to regain access") + return + } + ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{ TeamID: teamID, UserID: userID, diff --git a/internal/api/server.go b/internal/api/server.go index 1069d22..b33b145 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -51,6 +51,7 @@ func New( templateSvc := &service.TemplateService{DB: queries} hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret, Pool: pool, CA: ca} teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool} + userSvc := &service.UserService{DB: queries} auditSvc := &service.AuditService{DB: queries} statsSvc := &service.StatsService{DB: queries, Pool: pgPool} buildSvc := &service.BuildService{DB: queries, Redis: rdb, Pool: pool, Scheduler: sched} @@ -67,7 +68,7 @@ func New( apiKeys := newAPIKeyHandler(apiKeySvc, al) hostH := newHostHandler(hostSvc, queries, al) teamH := newTeamHandler(teamSvc, al) - usersH := newUsersHandler(queries) + usersH := newUsersHandler(queries, userSvc) auditH := newAuditHandler(auditSvc) statsH := newStatsHandler(statsSvc) metricsH := newSandboxMetricsHandler(queries, pool) @@ -88,11 +89,11 @@ func New( r.Get("/auth/oauth/{provider}/callback", oauthH.Callback) // JWT-authenticated: switch active team. - r.With(requireJWT(jwtSecret)).Post("/v1/auth/switch-team", authH.SwitchTeam) + r.With(requireJWT(jwtSecret, queries)).Post("/v1/auth/switch-team", authH.SwitchTeam) // JWT-authenticated: API key management. r.Route("/v1/api-keys", func(r chi.Router) { - r.Use(requireJWT(jwtSecret)) + r.Use(requireJWT(jwtSecret, queries)) r.Post("/", apiKeys.Create) r.Get("/", apiKeys.List) r.Delete("/{id}", apiKeys.Delete) @@ -100,7 +101,7 @@ func New( // JWT-authenticated: team management. r.Route("/v1/teams", func(r chi.Router) { - r.Use(requireJWT(jwtSecret)) + r.Use(requireJWT(jwtSecret, queries)) r.Get("/", teamH.List) r.Post("/", teamH.Create) r.Route("/{id}", func(r chi.Router) { @@ -116,7 +117,7 @@ func New( }) // JWT-authenticated: user search (for add-member UI). - r.With(requireJWT(jwtSecret)).Get("/v1/users/search", usersH.Search) + r.With(requireJWT(jwtSecret, queries)).Get("/v1/users/search", usersH.Search) // Capsule lifecycle: accepts API key or JWT bearer token. r.Route("/v1/capsules", func(r chi.Router) { @@ -169,7 +170,7 @@ func New( // JWT-authenticated: host CRUD and tags. r.Group(func(r chi.Router) { - r.Use(requireJWT(jwtSecret)) + r.Use(requireJWT(jwtSecret, queries)) r.Post("/", hostH.Create) r.Get("/", hostH.List) r.Route("/{id}", func(r chi.Router) { @@ -186,7 +187,7 @@ func New( // JWT-authenticated: notification channels. r.Route("/v1/channels", func(r chi.Router) { - r.Use(requireJWT(jwtSecret)) + r.Use(requireJWT(jwtSecret, queries)) r.Post("/", channelH.Create) r.Get("/", channelH.List) r.Post("/test", channelH.Test) @@ -199,13 +200,17 @@ func New( }) // JWT-authenticated: audit log. - r.With(requireJWT(jwtSecret)).Get("/v1/audit-logs", auditH.List) + r.With(requireJWT(jwtSecret, queries)).Get("/v1/audit-logs", auditH.List) // Platform admin routes — require JWT + DB-validated admin status. r.Route("/v1/admin", func(r chi.Router) { - r.Use(requireJWT(jwtSecret)) + r.Use(requireJWT(jwtSecret, queries)) r.Use(requireAdmin(queries)) + r.Get("/teams", teamH.AdminListTeams) r.Put("/teams/{id}/byoc", teamH.SetBYOC) + r.Delete("/teams/{id}", teamH.AdminDeleteTeam) + r.Get("/users", usersH.AdminListUsers) + r.Put("/users/{id}/active", usersH.SetUserActive) r.Get("/templates", buildH.ListTemplates) r.Delete("/templates/{name}", buildH.DeleteTemplate) r.Post("/builds", buildH.Create) diff --git a/internal/db/models.go b/internal/db/models.go index 45c00da..82829b7 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -197,6 +197,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"` } type UsersTeam struct { diff --git a/internal/db/teams.sql.go b/internal/db/teams.sql.go index 334141f..0700db4 100644 --- a/internal/db/teams.sql.go +++ b/internal/db/teams.sql.go @@ -11,6 +11,19 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countTeamsAdmin = `-- name: CountTeamsAdmin :one +SELECT COUNT(*)::int AS total +FROM teams +WHERE id != '00000000-0000-0000-0000-000000000000' +` + +func (q *Queries) CountTeamsAdmin(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, countTeamsAdmin) + var total int32 + err := row.Scan(&total) + return total, err +} + const deleteTeamMember = `-- name: DeleteTeamMember :exec DELETE FROM users_teams WHERE team_id = $1 AND user_id = $2 ` @@ -271,6 +284,78 @@ func (q *Queries) InsertTeamMember(ctx context.Context, arg InsertTeamMemberPara return err } +const listTeamsAdmin = `-- name: ListTeamsAdmin :many +SELECT + t.id, + t.name, + t.slug, + t.is_byoc, + t.created_at, + t.deleted_at, + (SELECT COUNT(*) FROM users_teams ut WHERE ut.team_id = t.id)::int AS member_count, + COALESCE(owner_u.name, '') AS owner_name, + COALESCE(owner_u.email, '') AS owner_email, + (SELECT COUNT(*) FROM sandboxes s WHERE s.team_id = t.id AND s.status IN ('running', 'paused', 'starting'))::int AS active_sandbox_count, + (SELECT COUNT(*) FROM channels c WHERE c.team_id = t.id)::int AS channel_count +FROM teams t +LEFT JOIN users_teams owner_ut ON owner_ut.team_id = t.id AND owner_ut.role = 'owner' +LEFT JOIN users owner_u ON owner_u.id = owner_ut.user_id +WHERE t.id != '00000000-0000-0000-0000-000000000000' +ORDER BY t.deleted_at ASC NULLS FIRST, t.created_at DESC +LIMIT $1 OFFSET $2 +` + +type ListTeamsAdminParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type ListTeamsAdminRow struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + IsByoc bool `json:"is_byoc"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + MemberCount int32 `json:"member_count"` + OwnerName string `json:"owner_name"` + OwnerEmail string `json:"owner_email"` + ActiveSandboxCount int32 `json:"active_sandbox_count"` + ChannelCount int32 `json:"channel_count"` +} + +func (q *Queries) ListTeamsAdmin(ctx context.Context, arg ListTeamsAdminParams) ([]ListTeamsAdminRow, error) { + rows, err := q.db.Query(ctx, listTeamsAdmin, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTeamsAdminRow + for rows.Next() { + var i ListTeamsAdminRow + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Slug, + &i.IsByoc, + &i.CreatedAt, + &i.DeletedAt, + &i.MemberCount, + &i.OwnerName, + &i.OwnerEmail, + &i.ActiveSandboxCount, + &i.ChannelCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setTeamBYOC = `-- name: SetTeamBYOC :exec UPDATE teams SET is_byoc = $2 WHERE id = $1 ` diff --git a/internal/db/users.sql.go b/internal/db/users.sql.go index 9be8fdd..73ebe52 100644 --- a/internal/db/users.sql.go +++ b/internal/db/users.sql.go @@ -22,6 +22,19 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) { return count, err } +const countUsersAdmin = `-- name: CountUsersAdmin :one +SELECT COUNT(*)::int AS total +FROM users +WHERE deleted_at IS NULL +` + +func (q *Queries) CountUsersAdmin(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, countUsersAdmin) + var total int32 + err := row.Scan(&total) + return total, err +} + const deleteAdminPermission = `-- name: DeleteAdminPermission :exec DELETE FROM admin_permissions WHERE user_id = $1 AND permission = $2 ` @@ -66,7 +79,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 FROM users WHERE is_admin = TRUE ORDER BY created_at +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 ` func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { @@ -86,6 +99,8 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) { &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, + &i.IsActive, + &i.DeletedAt, ); err != nil { return nil, err } @@ -98,7 +113,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 FROM users WHERE email = $1 +SELECT id, email, password_hash, name, is_admin, created_at, updated_at, is_active, deleted_at FROM users WHERE email = $1 ` func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { @@ -112,12 +127,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, + &i.IsActive, + &i.DeletedAt, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, email, password_hash, name, is_admin, created_at, updated_at FROM users WHERE id = $1 +SELECT id, email, password_hash, name, is_admin, created_at, updated_at, is_active, deleted_at FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { @@ -131,6 +148,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, + &i.IsActive, + &i.DeletedAt, ) return i, err } @@ -172,7 +191,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 +RETURNING id, email, password_hash, name, is_admin, created_at, updated_at, is_active, deleted_at ` type InsertUserParams struct { @@ -198,6 +217,8 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, + &i.IsActive, + &i.DeletedAt, ) return i, err } @@ -205,7 +226,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 +RETURNING id, email, password_hash, name, is_admin, created_at, updated_at, is_active, deleted_at ` type InsertUserOAuthParams struct { @@ -225,10 +246,73 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams &i.IsAdmin, &i.CreatedAt, &i.UpdatedAt, + &i.IsActive, + &i.DeletedAt, ) return i, err } +const listUsersAdmin = `-- name: ListUsersAdmin :many +SELECT + u.id, + u.email, + u.name, + u.is_admin, + u.is_active, + 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 +FROM users u +WHERE u.deleted_at IS NULL +ORDER BY u.created_at DESC +LIMIT $1 OFFSET $2 +` + +type ListUsersAdminParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` +} + +type ListUsersAdminRow struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + IsAdmin bool `json:"is_admin"` + IsActive bool `json:"is_active"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + TeamsJoined int32 `json:"teams_joined"` + TeamsOwned int32 `json:"teams_owned"` +} + +func (q *Queries) ListUsersAdmin(ctx context.Context, arg ListUsersAdminParams) ([]ListUsersAdminRow, error) { + rows, err := q.db.Query(ctx, listUsersAdmin, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListUsersAdminRow + for rows.Next() { + var i ListUsersAdminRow + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Name, + &i.IsAdmin, + &i.IsActive, + &i.CreatedAt, + &i.TeamsJoined, + &i.TeamsOwned, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const searchUsersByEmailPrefix = `-- name: SearchUsersByEmailPrefix :many SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10 ` @@ -258,6 +342,20 @@ 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 ` diff --git a/internal/service/team.go b/internal/service/team.go index f386338..ece4078 100644 --- a/internal/service/team.go +++ b/internal/service/team.go @@ -441,3 +441,109 @@ func (s *TeamService) SetBYOC(ctx context.Context, teamID pgtype.UUID, enabled b } return nil } + +// AdminTeamRow is the shape returned by AdminListTeams. +type AdminTeamRow struct { + ID pgtype.UUID + Name string + Slug string + IsByoc bool + CreatedAt time.Time + DeletedAt *time.Time + MemberCount int32 + OwnerName string + OwnerEmail string + ActiveSandboxCount int32 + ChannelCount int32 +} + +// AdminListTeams returns a paginated list of all teams (excluding the platform +// team) with member counts, owner info, and active sandbox counts. +// Admin-only — caller must verify admin status. +func (s *TeamService) AdminListTeams(ctx context.Context, limit, offset int32) ([]AdminTeamRow, int32, error) { + teams, err := s.DB.ListTeamsAdmin(ctx, db.ListTeamsAdminParams{ + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, 0, fmt.Errorf("list teams: %w", err) + } + + total, err := s.DB.CountTeamsAdmin(ctx) + if err != nil { + return nil, 0, fmt.Errorf("count teams: %w", err) + } + + rows := make([]AdminTeamRow, len(teams)) + for i, t := range teams { + row := AdminTeamRow{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + IsByoc: t.IsByoc, + CreatedAt: t.CreatedAt.Time, + MemberCount: t.MemberCount, + OwnerName: t.OwnerName, + OwnerEmail: t.OwnerEmail, + ActiveSandboxCount: t.ActiveSandboxCount, + ChannelCount: t.ChannelCount, + } + if t.DeletedAt.Valid { + deletedAt := t.DeletedAt.Time + row.DeletedAt = &deletedAt + } + rows[i] = row + } + return rows, total, nil +} + +// 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). +func (s *TeamService) AdminDeleteTeam(ctx context.Context, teamID pgtype.UUID) error { + team, err := s.DB.GetTeam(ctx, teamID) + if err != nil { + return fmt.Errorf("team not found: %w", err) + } + if team.DeletedAt.Valid { + 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 +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..2b90c61 --- /dev/null +++ b/internal/service/user.go @@ -0,0 +1,70 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.omukk.dev/wrenn/wrenn/internal/db" +) + +// UserService provides user management operations. +type UserService struct { + DB *db.Queries +} + +// AdminUserRow is the shape returned by AdminListUsers. +type AdminUserRow struct { + ID pgtype.UUID + Email string + Name string + IsAdmin bool + IsActive bool + CreatedAt time.Time + TeamsJoined int32 + TeamsOwned int32 +} + +// AdminListUsers returns a paginated list of all non-deleted users with team counts. +func (s *UserService) AdminListUsers(ctx context.Context, limit, offset int32) ([]AdminUserRow, int32, error) { + users, err := s.DB.ListUsersAdmin(ctx, db.ListUsersAdminParams{ + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, 0, fmt.Errorf("list users: %w", err) + } + + total, err := s.DB.CountUsersAdmin(ctx) + if err != nil { + return nil, 0, fmt.Errorf("count users: %w", err) + } + + rows := make([]AdminUserRow, len(users)) + for i, u := range users { + rows[i] = AdminUserRow{ + ID: u.ID, + Email: u.Email, + Name: u.Name, + IsAdmin: u.IsAdmin, + IsActive: u.IsActive, + CreatedAt: u.CreatedAt.Time, + TeamsJoined: u.TeamsJoined, + TeamsOwned: u.TeamsOwned, + } + } + return rows, total, nil +} + +// SetUserActive enables or disables a user account. +func (s *UserService) SetUserActive(ctx context.Context, userID pgtype.UUID, active bool) error { + if err := s.DB.SetUserActive(ctx, db.SetUserActiveParams{ + ID: userID, + IsActive: active, + }); err != nil { + return fmt.Errorf("set user active: %w", err) + } + return nil +}