From d332630267bae303cf3c9a90fd018b0dc26cd6e0 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 15 Apr 2026 03:36:37 +0600 Subject: [PATCH 1/2] Add admin teams management page Admin panel now includes a Teams page with paginated listing of all teams (including soft-deleted), BYOC enable with confirmation dialog, and team deletion with active capsule warnings. Shows member count, owner info, active capsules, and channel count per team. --- db/queries/teams.sql | 25 + frontend/src/lib/api/team.ts | 36 ++ .../src/lib/components/AdminSidebar.svelte | 6 +- frontend/src/routes/admin/teams/+page.svelte | 509 ++++++++++++++++++ internal/api/handlers_team.go | 85 +++ internal/api/server.go | 2 + internal/db/teams.sql.go | 85 +++ internal/service/team.go | 106 ++++ 8 files changed, 852 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/admin/teams/+page.svelte 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/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..3ea8a7b 100644 --- a/frontend/src/lib/components/AdminSidebar.svelte +++ b/frontend/src/lib/components/AdminSidebar.svelte @@ -11,7 +11,8 @@ IconBell, IconDocs, IconChevron, - IconShield + IconShield, + IconMembers } from './icons'; let { collapsed = $bindable(false) }: { collapsed: boolean } = $props(); @@ -25,7 +26,8 @@ const managementItems: NavItem[] = [ { label: 'Templates', icon: IconBox, href: '/admin/templates' }, { label: 'Capsules', icon: IconMonitor, href: '/admin/capsules' }, - { label: 'Hosts', icon: IconServer, href: '/admin/hosts' } + { label: 'Hosts', icon: IconServer, href: '/admin/hosts' }, + { label: 'Teams', icon: IconMembers, href: '/admin/teams' } ]; function isActive(href: string): boolean { 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/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/server.go b/internal/api/server.go index 1069d22..779e7be 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -205,7 +205,9 @@ func New( r.Route("/v1/admin", func(r chi.Router) { r.Use(requireJWT(jwtSecret)) r.Use(requireAdmin(queries)) + r.Get("/teams", teamH.AdminListTeams) r.Put("/teams/{id}/byoc", teamH.SetBYOC) + r.Delete("/teams/{id}", teamH.AdminDeleteTeam) r.Get("/templates", buildH.ListTemplates) r.Delete("/templates/{name}", buildH.DeleteTemplate) r.Post("/builds", buildH.Create) 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/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 +} From a265c15c4dc56474ea33e382d8fb5512b249b1d8 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 15 Apr 2026 03:58:44 +0600 Subject: [PATCH 2/2] Add admin user management with is_active enforcement Admin users page at /admin/users with paginated user list showing name, email, team counts, role, join date, and active status toggle. Inactive users are blocked from all authenticated endpoints immediately via DB check in JWT middleware. OAuth login errors now show human-readable messages on the login page. --- ...20260414213729_add_user_active_deleted.sql | 7 + db/queries/users.sql | 23 ++ frontend/src/lib/api/admin-users.ts | 28 ++ .../src/lib/components/AdminSidebar.svelte | 90 +++--- frontend/src/routes/admin/users/+page.svelte | 305 ++++++++++++++++++ frontend/src/routes/login/+page.svelte | 12 +- internal/api/handlers_auth.go | 6 + internal/api/handlers_oauth.go | 10 + internal/api/handlers_users.go | 99 +++++- internal/api/middleware_auth.go | 12 + internal/api/middleware_jwt.go | 17 +- internal/api/server.go | 21 +- internal/db/models.go | 2 + internal/db/users.sql.go | 108 ++++++- internal/service/user.go | 70 ++++ 15 files changed, 751 insertions(+), 59 deletions(-) create mode 100644 db/migrations/20260414213729_add_user_active_deleted.sql create mode 100644 frontend/src/lib/api/admin-users.ts create mode 100644 frontend/src/routes/admin/users/+page.svelte create mode 100644 internal/service/user.go 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/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/components/AdminSidebar.svelte b/frontend/src/lib/components/AdminSidebar.svelte index 3ea8a7b..e7421b0 100644 --- a/frontend/src/lib/components/AdminSidebar.svelte +++ b/frontend/src/lib/components/AdminSidebar.svelte @@ -12,7 +12,8 @@ IconDocs, IconChevron, IconShield, - IconMembers + IconMembers, + IconUser } from './icons'; let { collapsed = $bindable(false) }: { collapsed: boolean } = $props(); @@ -24,10 +25,14 @@ }; 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' }, - { label: 'Teams', icon: IconMembers, href: '/admin/teams' } + { label: 'Hosts', icon: IconServer, href: '/admin/hosts' } ]; function isActive(href: string): boolean { @@ -100,43 +105,8 @@ @@ -188,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/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_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 779e7be..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,15 +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/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/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 +}