1
0
forked from wrenn/wrenn

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.
This commit is contained in:
2026-04-15 03:58:44 +06:00
parent d332630267
commit a265c15c4d
15 changed files with 751 additions and 59 deletions

View File

@ -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;

View File

@ -43,3 +43,26 @@ SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10;
-- name: UpdateUserName :exec -- name: UpdateUserName :exec
UPDATE users SET name = $2, updated_at = NOW() WHERE id = $1; 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;

View File

@ -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<ApiResult<AdminUsersResponse>> {
return apiFetch('GET', `/api/v1/admin/users?page=${page}`);
}
export async function setUserActive(id: string, active: boolean): Promise<ApiResult<void>> {
return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active });
}

View File

@ -12,7 +12,8 @@
IconDocs, IconDocs,
IconChevron, IconChevron,
IconShield, IconShield,
IconMembers IconMembers,
IconUser
} from './icons'; } from './icons';
let { collapsed = $bindable(false) }: { collapsed: boolean } = $props(); let { collapsed = $bindable(false) }: { collapsed: boolean } = $props();
@ -24,10 +25,14 @@
}; };
const managementItems: NavItem[] = [ 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: 'Templates', icon: IconBox, href: '/admin/templates' },
{ label: 'Capsules', icon: IconMonitor, href: '/admin/capsules' }, { 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 { function isActive(href: string): boolean {
@ -100,43 +105,8 @@
<!-- Navigation --> <!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-3 {collapsed ? 'px-1.5' : ''}"> <nav class="flex-1 overflow-y-auto px-3 {collapsed ? 'px-1.5' : ''}">
<div class="mb-3"> {@render navSection('Platform', platformItems)}
{#if !collapsed} {@render navSection('Management', managementItems)}
<div class="mb-1 px-2.5 py-1.5 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
Management
</div>
{:else}
<div class="mx-1 my-2 h-px bg-[var(--color-border)]"></div>
{/if}
{#each managementItems as item}
{#if isActive(item.href)}
<a
href={item.href}
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
{#if !collapsed}
<div class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
{/if}
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
{#if !collapsed}
<span class="text-ui font-medium text-[var(--color-accent-bright)]">{item.label}</span>
{/if}
</a>
{:else}
<a
href={item.href}
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
<item.icon size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}
<span class="text-ui text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]">{item.label}</span>
{/if}
</a>
{/if}
{/each}
</div>
</nav> </nav>
<!-- Bottom links --> <!-- Bottom links -->
@ -188,3 +158,43 @@
</button> </button>
</div> </div>
</aside> </aside>
{#snippet navSection(title: string, items: NavItem[])}
<div class="mb-3">
{#if !collapsed}
<div class="mb-1 px-2.5 py-1.5 text-label font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
{title}
</div>
{:else}
<div class="mx-1 my-2 h-px bg-[var(--color-border)]"></div>
{/if}
{#each items as item}
{#if isActive(item.href)}
<a
href={item.href}
class="group relative flex items-center rounded-[var(--radius-input)] bg-[var(--color-accent-glow-mid)] px-2.5 py-2.5 transition-colors duration-150 {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
{#if !collapsed}
<div class="absolute left-0 top-1/2 h-5 w-[3px] -translate-y-1/2 rounded-r-full bg-[var(--color-accent)]"></div>
{/if}
<item.icon size={16} class="shrink-0 text-[var(--color-accent-bright)]" />
{#if !collapsed}
<span class="text-ui font-medium text-[var(--color-accent-bright)]">{item.label}</span>
{/if}
</a>
{:else}
<a
href={item.href}
class="group flex items-center rounded-[var(--radius-input)] px-2.5 py-2.5 transition-colors duration-150 hover:bg-[var(--color-bg-3)] {collapsed ? 'justify-center px-2' : 'gap-3'}"
title={collapsed ? item.label : undefined}
>
<item.icon size={16} class="shrink-0 opacity-50 transition-opacity duration-150 group-hover:opacity-100" />
{#if !collapsed}
<span class="text-ui text-[var(--color-text-primary)] transition-colors duration-150 group-hover:text-[var(--color-text-bright)]">{item.label}</span>
{/if}
</a>
{/if}
{/each}
</div>
{/snippet}

View File

@ -0,0 +1,305 @@
<script lang="ts">
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
import { onMount } from 'svelte';
import { toast } from '$lib/toast.svelte';
import { formatDate } from '$lib/utils/format';
import {
listAdminUsers,
setUserActive,
type AdminUser,
} from '$lib/api/admin-users';
let collapsed = $state(
typeof window !== 'undefined'
? localStorage.getItem('wrenn_sidebar_collapsed') === 'true'
: false
);
// Data state
let users = $state<AdminUser[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let currentPage = $state(1);
let totalPages = $state(1);
let totalUsers = $state(0);
// Animation
let initialAnimationDone = $state(false);
// Toggle state
let togglingId = $state<string | null>(null);
async function fetchUsers(page: number = 1) {
const wasEmpty = users.length === 0;
if (wasEmpty) loading = true;
error = null;
const result = await listAdminUsers(page);
if (result.ok) {
users = result.data.users;
currentPage = result.data.page;
totalPages = result.data.total_pages;
totalUsers = result.data.total;
} else {
error = result.error;
}
loading = false;
if (!initialAnimationDone) {
setTimeout(() => { initialAnimationDone = true; }, 400 + (users.length * 30));
}
}
async function handleToggleActive(user: AdminUser) {
togglingId = user.id;
const newActive = !user.is_active;
const result = await setUserActive(user.id, newActive);
if (result.ok) {
user.is_active = newActive;
toast.success(`${user.email} ${newActive ? 'activated' : 'deactivated'}`);
} else {
toast.error(result.error);
}
togglingId = null;
}
function goToPage(page: number) {
if (page < 1 || page > totalPages) return;
fetchUsers(page);
}
onMount(() => {
fetchUsers();
});
</script>
<svelte:head>
<title>Wrenn Admin — Users</title>
</svelte:head>
<style>
.user-grid {
display: grid;
grid-template-columns: 1.6fr 1.4fr 0.7fr 0.7fr 0.5fr 1fr 0.6fr;
}
.stat-pill {
display: flex;
align-items: baseline;
gap: 6px;
border-radius: var(--radius-button);
border-width: 1px;
padding: 6px 12px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.stat-pill:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.row-stripe {
transform: scaleY(0);
transform-origin: center;
transition: transform 0.18s cubic-bezier(0.25, 1, 0.5, 1);
}
.user-row:hover .row-stripe {
transform: scaleY(1);
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<div class="flex h-screen overflow-hidden bg-[var(--color-bg-0)]">
<AdminSidebar bind:collapsed />
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
<!-- Header -->
<header class="relative shrink-0 border-b border-[var(--color-border)] bg-[var(--color-bg-1)]">
<div class="absolute inset-0 bg-gradient-to-b from-[var(--color-accent)]/[0.02] to-transparent pointer-events-none"></div>
<div class="relative flex items-start justify-between px-8 pt-7 pb-5">
<div>
<h1 class="font-serif text-page leading-none tracking-[-0.03em] text-[var(--color-text-bright)]">
Users
</h1>
<p class="mt-2 text-ui text-[var(--color-text-tertiary)]">
All registered users, team memberships, and account status.
</p>
</div>
</div>
<!-- Stat strip -->
{#if !loading && !error}
<div class="relative flex items-center gap-3 px-8 pb-5">
<div class="stat-pill border-[var(--color-border)] bg-[var(--color-bg-2)]">
<span class="font-mono text-body font-bold tabular-nums text-[var(--color-text-bright)]">{totalUsers}</span>
<span class="text-label text-[var(--color-text-muted)]">user{totalUsers !== 1 ? 's' : ''}</span>
</div>
</div>
{/if}
</header>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-8 py-6" style="animation: fadeUp 0.35s ease both">
{#if error}
<div class="mb-4 flex items-start gap-3 rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3">
<svg class="mt-0.5 shrink-0 text-[var(--color-red)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span class="text-ui text-[var(--color-red)]">{error}. Try refreshing the page.</span>
</div>
{/if}
<!-- Table -->
<div class="rounded-[var(--radius-card)] border border-[var(--color-border)] overflow-hidden">
<!-- Header row -->
<div class="user-grid rounded-t-[var(--radius-card)] border-b border-[var(--color-border)] bg-[var(--color-bg-3)]">
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Name</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Email</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Teams</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Owned</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Role</div>
<div class="px-5 py-3 text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Joined</div>
<div class="px-5 py-3 text-right text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-muted)]">Status</div>
</div>
{#if loading && users.length === 0}
<div class="flex items-center justify-center py-16">
<div class="flex items-center gap-3 text-ui text-[var(--color-text-secondary)]">
<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Loading users...
</div>
</div>
{:else if users.length === 0}
<div class="flex flex-col items-center justify-center py-[72px]">
<div class="relative mb-5">
<div class="absolute inset-0 -m-4 rounded-full" style="background: radial-gradient(circle, rgba(94,140,88,0.08) 0%, transparent 70%)"></div>
<div class="relative flex h-14 w-14 items-center justify-center rounded-[var(--radius-card)] border border-[var(--color-accent)]/20 bg-[var(--color-bg-3)]">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-accent-mid)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
</svg>
</div>
</div>
<p class="font-serif text-heading tracking-[-0.02em] text-[var(--color-text-bright)]">
No users yet
</p>
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
Users appear here when they sign up.
</p>
</div>
{:else}
{#each users as user, i (user.id)}
<div
class="user-row user-grid relative items-center overflow-hidden border-b border-[var(--color-border)] transition-colors duration-150 last:border-b-0 {!user.is_active ? 'opacity-50' : 'hover:bg-[var(--color-bg-3)]'}"
style={initialAnimationDone ? '' : `animation: fadeUp 0.35s ease both; animation-delay: ${i * 30}ms`}
>
<!-- Left accent stripe -->
{#if user.is_active}
<div class="row-stripe pointer-events-none absolute left-0 top-0 h-full w-0.5 bg-[var(--color-accent)]"></div>
{/if}
<!-- Name -->
<div class="min-w-0 px-5 py-4">
<div class="flex items-center gap-2">
<span class="block truncate text-ui font-medium text-[var(--color-text-bright)]">{user.name || '\u2014'}</span>
{#if user.is_admin}
<span class="inline-flex shrink-0 items-center rounded-full border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.05em] text-[var(--color-amber)]">
Admin
</span>
{/if}
</div>
<span class="block truncate font-mono text-label text-[var(--color-text-muted)]">{user.id}</span>
</div>
<!-- Email -->
<div class="min-w-0 px-5 py-4">
<span class="block truncate font-mono text-ui text-[var(--color-text-secondary)]">{user.email}</span>
</div>
<!-- Teams Joined -->
<div class="px-5 py-4">
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{user.teams_joined}</span>
</div>
<!-- Teams Owned -->
<div class="px-5 py-4">
<span class="font-mono text-ui text-[var(--color-text-secondary)]">{user.teams_owned}</span>
</div>
<!-- Role -->
<div class="px-5 py-4">
<span class="text-ui text-[var(--color-text-secondary)]">{user.is_admin ? 'Admin' : 'User'}</span>
</div>
<!-- Joined -->
<div class="px-5 py-4">
<span class="text-ui text-[var(--color-text-secondary)]">{formatDate(user.created_at)}</span>
</div>
<!-- Status / Toggle -->
<div class="flex items-center justify-end px-5 py-4">
<button
onclick={() => handleToggleActive(user)}
disabled={togglingId === user.id}
class="rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-medium transition-all duration-150 disabled:opacity-50
{user.is_active
? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/8 text-[var(--color-accent-bright)] hover:bg-[var(--color-accent)]/15 hover:border-[var(--color-accent)]/50'
: 'border-[var(--color-red)]/30 bg-[var(--color-red)]/8 text-[var(--color-red)] hover:bg-[var(--color-red)]/15 hover:border-[var(--color-red)]/50'}"
>
{#if togglingId === user.id}
<svg class="inline animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
{:else}
{user.is_active ? 'Active' : 'Inactive'}
{/if}
</button>
</div>
</div>
{/each}
{/if}
</div>
<!-- Pagination -->
{#if totalPages > 1}
<div class="mt-4 flex items-center justify-between">
<span class="text-ui text-[var(--color-text-tertiary)]">
Page <span class="font-mono text-[var(--color-text-secondary)]">{currentPage}</span> of <span class="font-mono text-[var(--color-text-secondary)]">{totalPages}</span>
</span>
<div class="flex items-center gap-2">
<button
onclick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-40 disabled:hover:border-[var(--color-border)] disabled:hover:text-[var(--color-text-secondary)]"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
Previous
</button>
<button
onclick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
class="flex items-center gap-1.5 rounded-[var(--radius-button)] border border-[var(--color-border)] px-3 py-1.5 text-ui text-[var(--color-text-secondary)] transition-colors duration-150 hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-primary)] disabled:opacity-40 disabled:hover:border-[var(--color-border)] disabled:hover:text-[var(--color-text-secondary)]"
>
Next
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
{/if}
</div>
<!-- Status bar -->
<footer class="flex h-7 shrink-0 items-center justify-end border-t border-[var(--color-border)] bg-[var(--color-bg-1)] px-8">
<div class="flex items-center gap-1.5">
<span class="relative flex h-[5px] w-[5px]">
<span class="animate-status-ping absolute inline-flex h-full w-full rounded-full bg-[var(--color-accent)]"></span>
<span class="relative inline-flex h-[5px] w-[5px] rounded-full bg-[var(--color-accent)]"></span>
</span>
<span class="font-mono text-label uppercase tracking-[0.04em] text-[var(--color-text-secondary)]">All systems operational</span>
</div>
</footer>
</main>
</div>

View File

@ -22,10 +22,20 @@
let error = $state(''); let error = $state('');
let loading = $state(false); let loading = $state(false);
const oauthErrorMessages: Record<string, string> = {
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 // Read OAuth error forwarded from /auth/github/callback
onMount(() => { onMount(() => {
const urlErr = $page.url.searchParams.get('error'); 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 // Mouse-reactive glow — moves opposite to cursor with viscous drag

View File

@ -237,6 +237,12 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) {
return 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) team, role, err := loginTeam(ctx, h.db, user.ID)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {

View File

@ -150,6 +150,11 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
redirectWithError(w, r, redirectBase, "db_error") redirectWithError(w, r, redirectBase, "db_error")
return 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) team, role, err := loginTeam(ctx, h.db, user.ID)
if err != nil { if err != nil {
slog.Error("oauth login: failed to get team", "error", err) 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") redirectWithError(w, r, redirectBase, "db_error")
return 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) team, role, err := loginTeam(ctx, h.db, user.ID)
if err != nil { if err != nil {
slog.Error("oauth: retry login: failed to get team", "error", err) slog.Error("oauth: retry login: failed to get team", "error", err)

View File

@ -1,22 +1,27 @@
package api package api
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.omukk.dev/wrenn/wrenn/internal/auth" "git.omukk.dev/wrenn/wrenn/internal/auth"
"git.omukk.dev/wrenn/wrenn/internal/db" "git.omukk.dev/wrenn/wrenn/internal/db"
"git.omukk.dev/wrenn/wrenn/internal/id" "git.omukk.dev/wrenn/wrenn/internal/id"
"git.omukk.dev/wrenn/wrenn/internal/service"
) )
type usersHandler struct { type usersHandler struct {
db *db.Queries db *db.Queries
svc *service.UserService
} }
func newUsersHandler(db *db.Queries) *usersHandler { func newUsersHandler(db *db.Queries, svc *service.UserService) *usersHandler {
return &usersHandler{db: db} return &usersHandler{db: db, svc: svc}
} }
// Search handles GET /v1/users/search?email=<prefix> // Search handles GET /v1/users/search?email=<prefix>
@ -50,3 +55,91 @@ func (h *usersHandler) Search(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, http.StatusOK, resp) 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)
}

View File

@ -64,6 +64,18 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler
return 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{ ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{
TeamID: teamID, TeamID: teamID,
UserID: userID, UserID: userID,

View File

@ -1,16 +1,19 @@
package api package api
import ( import (
"log/slog"
"net/http" "net/http"
"strings" "strings"
"git.omukk.dev/wrenn/wrenn/internal/auth" "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/id"
) )
// requireJWT validates a JWT from the Authorization: Bearer header or the // requireJWT validates a JWT from the Authorization: Bearer header or the
// ?token= query parameter (for WebSocket connections that cannot send headers). // ?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 func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var tokenStr string var tokenStr string
@ -40,6 +43,18 @@ func requireJWT(secret []byte) func(http.Handler) http.Handler {
return 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{ ctx := auth.WithAuthContext(r.Context(), auth.AuthContext{
TeamID: teamID, TeamID: teamID,
UserID: userID, UserID: userID,

View File

@ -51,6 +51,7 @@ func New(
templateSvc := &service.TemplateService{DB: queries} templateSvc := &service.TemplateService{DB: queries}
hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret, Pool: pool, CA: ca} hostSvc := &service.HostService{DB: queries, Redis: rdb, JWT: jwtSecret, Pool: pool, CA: ca}
teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool} teamSvc := &service.TeamService{DB: queries, Pool: pgPool, HostPool: pool}
userSvc := &service.UserService{DB: queries}
auditSvc := &service.AuditService{DB: queries} auditSvc := &service.AuditService{DB: queries}
statsSvc := &service.StatsService{DB: queries, Pool: pgPool} statsSvc := &service.StatsService{DB: queries, Pool: pgPool}
buildSvc := &service.BuildService{DB: queries, Redis: rdb, Pool: pool, Scheduler: sched} buildSvc := &service.BuildService{DB: queries, Redis: rdb, Pool: pool, Scheduler: sched}
@ -67,7 +68,7 @@ func New(
apiKeys := newAPIKeyHandler(apiKeySvc, al) apiKeys := newAPIKeyHandler(apiKeySvc, al)
hostH := newHostHandler(hostSvc, queries, al) hostH := newHostHandler(hostSvc, queries, al)
teamH := newTeamHandler(teamSvc, al) teamH := newTeamHandler(teamSvc, al)
usersH := newUsersHandler(queries) usersH := newUsersHandler(queries, userSvc)
auditH := newAuditHandler(auditSvc) auditH := newAuditHandler(auditSvc)
statsH := newStatsHandler(statsSvc) statsH := newStatsHandler(statsSvc)
metricsH := newSandboxMetricsHandler(queries, pool) metricsH := newSandboxMetricsHandler(queries, pool)
@ -88,11 +89,11 @@ func New(
r.Get("/auth/oauth/{provider}/callback", oauthH.Callback) r.Get("/auth/oauth/{provider}/callback", oauthH.Callback)
// JWT-authenticated: switch active team. // 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. // JWT-authenticated: API key management.
r.Route("/v1/api-keys", func(r chi.Router) { r.Route("/v1/api-keys", func(r chi.Router) {
r.Use(requireJWT(jwtSecret)) r.Use(requireJWT(jwtSecret, queries))
r.Post("/", apiKeys.Create) r.Post("/", apiKeys.Create)
r.Get("/", apiKeys.List) r.Get("/", apiKeys.List)
r.Delete("/{id}", apiKeys.Delete) r.Delete("/{id}", apiKeys.Delete)
@ -100,7 +101,7 @@ func New(
// JWT-authenticated: team management. // JWT-authenticated: team management.
r.Route("/v1/teams", func(r chi.Router) { r.Route("/v1/teams", func(r chi.Router) {
r.Use(requireJWT(jwtSecret)) r.Use(requireJWT(jwtSecret, queries))
r.Get("/", teamH.List) r.Get("/", teamH.List)
r.Post("/", teamH.Create) r.Post("/", teamH.Create)
r.Route("/{id}", func(r chi.Router) { r.Route("/{id}", func(r chi.Router) {
@ -116,7 +117,7 @@ func New(
}) })
// JWT-authenticated: user search (for add-member UI). // 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. // Capsule lifecycle: accepts API key or JWT bearer token.
r.Route("/v1/capsules", func(r chi.Router) { r.Route("/v1/capsules", func(r chi.Router) {
@ -169,7 +170,7 @@ func New(
// JWT-authenticated: host CRUD and tags. // JWT-authenticated: host CRUD and tags.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(requireJWT(jwtSecret)) r.Use(requireJWT(jwtSecret, queries))
r.Post("/", hostH.Create) r.Post("/", hostH.Create)
r.Get("/", hostH.List) r.Get("/", hostH.List)
r.Route("/{id}", func(r chi.Router) { r.Route("/{id}", func(r chi.Router) {
@ -186,7 +187,7 @@ func New(
// JWT-authenticated: notification channels. // JWT-authenticated: notification channels.
r.Route("/v1/channels", func(r chi.Router) { r.Route("/v1/channels", func(r chi.Router) {
r.Use(requireJWT(jwtSecret)) r.Use(requireJWT(jwtSecret, queries))
r.Post("/", channelH.Create) r.Post("/", channelH.Create)
r.Get("/", channelH.List) r.Get("/", channelH.List)
r.Post("/test", channelH.Test) r.Post("/test", channelH.Test)
@ -199,15 +200,17 @@ func New(
}) })
// JWT-authenticated: audit log. // 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. // Platform admin routes — require JWT + DB-validated admin status.
r.Route("/v1/admin", func(r chi.Router) { r.Route("/v1/admin", func(r chi.Router) {
r.Use(requireJWT(jwtSecret)) r.Use(requireJWT(jwtSecret, queries))
r.Use(requireAdmin(queries)) r.Use(requireAdmin(queries))
r.Get("/teams", teamH.AdminListTeams) r.Get("/teams", teamH.AdminListTeams)
r.Put("/teams/{id}/byoc", teamH.SetBYOC) r.Put("/teams/{id}/byoc", teamH.SetBYOC)
r.Delete("/teams/{id}", teamH.AdminDeleteTeam) r.Delete("/teams/{id}", teamH.AdminDeleteTeam)
r.Get("/users", usersH.AdminListUsers)
r.Put("/users/{id}/active", usersH.SetUserActive)
r.Get("/templates", buildH.ListTemplates) r.Get("/templates", buildH.ListTemplates)
r.Delete("/templates/{name}", buildH.DeleteTemplate) r.Delete("/templates/{name}", buildH.DeleteTemplate)
r.Post("/builds", buildH.Create) r.Post("/builds", buildH.Create)

View File

@ -197,6 +197,8 @@ type User struct {
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
CreatedAt pgtype.Timestamptz `json:"created_at"` CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"`
IsActive bool `json:"is_active"`
DeletedAt pgtype.Timestamptz `json:"deleted_at"`
} }
type UsersTeam struct { type UsersTeam struct {

View File

@ -22,6 +22,19 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
return count, err 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 const deleteAdminPermission = `-- name: DeleteAdminPermission :exec
DELETE FROM admin_permissions WHERE user_id = $1 AND permission = $2 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 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) { 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.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -98,7 +113,7 @@ func (q *Queries) GetAdminUsers(ctx context.Context) ([]User, error) {
} }
const getUserByEmail = `-- name: GetUserByEmail :one 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) { 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.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt,
) )
return i, err return i, err
} }
const getUserByID = `-- name: GetUserByID :one 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) { 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.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt,
) )
return i, err return i, err
} }
@ -172,7 +191,7 @@ func (q *Queries) InsertAdminPermission(ctx context.Context, arg InsertAdminPerm
const insertUser = `-- name: InsertUser :one const insertUser = `-- name: InsertUser :one
INSERT INTO users (id, email, password_hash, name) INSERT INTO users (id, email, password_hash, name)
VALUES ($1, $2, $3, $4) 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 { type InsertUserParams struct {
@ -198,6 +217,8 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt,
) )
return i, err return i, err
} }
@ -205,7 +226,7 @@ func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (User, e
const insertUserOAuth = `-- name: InsertUserOAuth :one const insertUserOAuth = `-- name: InsertUserOAuth :one
INSERT INTO users (id, email, name) INSERT INTO users (id, email, name)
VALUES ($1, $2, $3) 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 { type InsertUserOAuthParams struct {
@ -225,10 +246,73 @@ func (q *Queries) InsertUserOAuth(ctx context.Context, arg InsertUserOAuthParams
&i.IsAdmin, &i.IsAdmin,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt, &i.UpdatedAt,
&i.IsActive,
&i.DeletedAt,
) )
return i, err 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 const searchUsersByEmailPrefix = `-- name: SearchUsersByEmailPrefix :many
SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10 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 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 const setUserAdmin = `-- name: SetUserAdmin :exec
UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1 UPDATE users SET is_admin = $2, updated_at = NOW() WHERE id = $1
` `

70
internal/service/user.go Normal file
View File

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