From d332630267bae303cf3c9a90fd018b0dc26cd6e0 Mon Sep 17 00:00:00 2001 From: pptx704 Date: Wed, 15 Apr 2026 03:36:37 +0600 Subject: [PATCH] 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 +}