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 +}