forked from wrenn/wrenn
Merge pull request 'Enhanced frontend ux' (#42) from enhance/frontend into dev
Reviewed-on: wrenn/wrenn#42
This commit is contained in:
@ -22,6 +22,12 @@ RETURNING *;
|
|||||||
-- name: SetUserAdmin :exec
|
-- 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;
|
||||||
|
|
||||||
|
-- name: RevokeUserAdmin :execrows
|
||||||
|
UPDATE users u SET is_admin = false, updated_at = NOW()
|
||||||
|
WHERE u.id = $1
|
||||||
|
AND u.is_admin = true
|
||||||
|
AND (SELECT COUNT(*) FROM users WHERE is_admin = true AND status != 'deleted') > 1;
|
||||||
|
|
||||||
-- name: GetAdminUsers :many
|
-- name: GetAdminUsers :many
|
||||||
SELECT * FROM users WHERE is_admin = TRUE ORDER BY created_at;
|
SELECT * FROM users WHERE is_admin = TRUE ORDER BY created_at;
|
||||||
|
|
||||||
|
|||||||
@ -10,12 +10,22 @@ use crate::state::AppState;
|
|||||||
/// POST /snapshot/prepare — quiesce subsystems before Firecracker snapshot.
|
/// POST /snapshot/prepare — quiesce subsystems before Firecracker snapshot.
|
||||||
///
|
///
|
||||||
/// In Rust there is no GC dance. We just:
|
/// In Rust there is no GC dance. We just:
|
||||||
/// 1. Stop port subsystem
|
/// 1. Drop page cache to shrink snapshot size
|
||||||
/// 2. Close idle connections via conntracker
|
/// 2. Stop port subsystem
|
||||||
/// 3. Set needs_restore flag
|
/// 3. Close idle connections via conntracker
|
||||||
|
/// 4. Set needs_restore flag
|
||||||
pub async fn post_snapshot_prepare(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
pub async fn post_snapshot_prepare(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
// Block memory reclaimer before anything else — prevents drop_caches
|
// Drop page cache BEFORE blocking the reclaimer — avoids snapshotting
|
||||||
// from running mid-freeze which would corrupt kernel page table state.
|
// gigabytes of stale cache that inflates the memory dump on disk.
|
||||||
|
// "1" = pagecache only (keep dentries/inodes for faster resume).
|
||||||
|
if let Err(e) = std::fs::write("/proc/sys/vm/drop_caches", "1") {
|
||||||
|
tracing::warn!(error = %e, "snapshot/prepare: drop_caches failed");
|
||||||
|
} else {
|
||||||
|
tracing::info!("snapshot/prepare: page cache dropped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block memory reclaimer — prevents drop_caches from running mid-freeze
|
||||||
|
// which would corrupt kernel page table state.
|
||||||
state.snapshot_in_progress.store(true, Ordering::Release);
|
state.snapshot_in_progress.store(true, Ordering::Release);
|
||||||
|
|
||||||
if let Some(ref ps) = state.port_subsystem {
|
if let Some(ref ps) = state.port_subsystem {
|
||||||
|
|||||||
@ -26,3 +26,7 @@ export async function listAdminUsers(page: number = 1): Promise<ApiResult<AdminU
|
|||||||
export async function setUserActive(id: string, active: boolean): Promise<ApiResult<void>> {
|
export async function setUserActive(id: string, active: boolean): Promise<ApiResult<void>> {
|
||||||
return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active });
|
return apiFetch('PUT', `/api/v1/admin/users/${id}/active`, { active });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setUserAdmin(id: string, admin: boolean): Promise<ApiResult<void>> {
|
||||||
|
return apiFetch('PUT', `/api/v1/admin/users/${id}/admin`, { admin });
|
||||||
|
}
|
||||||
|
|||||||
@ -213,6 +213,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lastDataKey = '';
|
||||||
updateCharts();
|
updateCharts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,6 +234,7 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
|
loadMetrics();
|
||||||
const mod = await import('chart.js/auto');
|
const mod = await import('chart.js/auto');
|
||||||
ChartJS = mod.Chart;
|
ChartJS = mod.Chart;
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
import CopyButton from '$lib/components/CopyButton.svelte';
|
import CopyButton from '$lib/components/CopyButton.svelte';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
import { formatDate, timeAgo } from '$lib/utils/format';
|
import { formatDate, timeAgo } from '$lib/utils/format';
|
||||||
import {
|
import {
|
||||||
listBuilds,
|
listBuilds,
|
||||||
@ -13,6 +14,7 @@
|
|||||||
type BuildLogEntry,
|
type BuildLogEntry,
|
||||||
type AdminTemplate
|
type AdminTemplate
|
||||||
} from '$lib/api/builds';
|
} from '$lib/api/builds';
|
||||||
|
import { listAdminTeams } from '$lib/api/team';
|
||||||
|
|
||||||
let activeTab = $state<'templates' | 'builds'>('templates');
|
let activeTab = $state<'templates' | 'builds'>('templates');
|
||||||
|
|
||||||
@ -35,6 +37,9 @@
|
|||||||
let expandedBuildId = $state<string | null>(null);
|
let expandedBuildId = $state<string | null>(null);
|
||||||
let expandedSteps = $state<Set<number>>(new Set());
|
let expandedSteps = $state<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Team name lookup
|
||||||
|
let teamNames = $state<Map<string, string>>(new Map());
|
||||||
|
|
||||||
// Delete template state
|
// Delete template state
|
||||||
let deleteTarget = $state<AdminTemplate | null>(null);
|
let deleteTarget = $state<AdminTemplate | null>(null);
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
@ -64,6 +69,28 @@
|
|||||||
let baseCount = $derived(templates.filter((t) => t.type === 'base').length);
|
let baseCount = $derived(templates.filter((t) => t.type === 'base').length);
|
||||||
let runningBuilds = $derived(builds.filter((b) => b.status === 'running').length);
|
let runningBuilds = $derived(builds.filter((b) => b.status === 'running').length);
|
||||||
|
|
||||||
|
async function fetchTeamNames() {
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const result = await listAdminTeams(page);
|
||||||
|
if (!result.ok) break;
|
||||||
|
for (const team of result.data.teams) {
|
||||||
|
names.set(team.id, team.name);
|
||||||
|
}
|
||||||
|
if (page >= result.data.total_pages) break;
|
||||||
|
page++;
|
||||||
|
}
|
||||||
|
teamNames = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_TEAM_ID = 'team-0000000000000000000000000';
|
||||||
|
|
||||||
|
function canDeleteTemplate(tmpl: AdminTemplate): boolean {
|
||||||
|
if (tmpl.name === 'minimal') return false;
|
||||||
|
return tmpl.team_id === PLATFORM_TEAM_ID;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchTemplates() {
|
async function fetchTemplates() {
|
||||||
templatesLoading = true;
|
templatesLoading = true;
|
||||||
templatesError = null;
|
templatesError = null;
|
||||||
@ -238,6 +265,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
fetchTeamNames();
|
||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
fetchBuilds().then(startPolling);
|
fetchBuilds().then(startPolling);
|
||||||
|
|
||||||
@ -339,7 +367,7 @@
|
|||||||
<div class="flex-1 overflow-y-auto px-8 py-6">
|
<div class="flex-1 overflow-y-auto px-8 py-6">
|
||||||
{#if activeTab === 'templates'}
|
{#if activeTab === 'templates'}
|
||||||
{#if templatesLoading}
|
{#if templatesLoading}
|
||||||
{@render skeletonRows(5, ['Name', 'Type', 'Specs', 'Size', 'Created', ''])}
|
{@render skeletonRows(5, ['Name', 'Type', 'Owner', 'Specs', 'Size', 'Created', ''])}
|
||||||
{:else if templatesError}
|
{:else if templatesError}
|
||||||
<div class="rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]">
|
<div class="rounded-[var(--radius-card)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-4 py-3 text-ui text-[var(--color-red)]">
|
||||||
{templatesError}
|
{templatesError}
|
||||||
@ -442,6 +470,7 @@
|
|||||||
<tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
|
<tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
|
||||||
<th class="table-header">Name</th>
|
<th class="table-header">Name</th>
|
||||||
<th class="table-header">Type</th>
|
<th class="table-header">Type</th>
|
||||||
|
<th class="table-header hidden md:table-cell">Owner</th>
|
||||||
<th class="table-header hidden md:table-cell">Specs</th>
|
<th class="table-header hidden md:table-cell">Specs</th>
|
||||||
<th class="table-header hidden lg:table-cell">Size</th>
|
<th class="table-header hidden lg:table-cell">Size</th>
|
||||||
<th class="table-header hidden lg:table-cell">Created</th>
|
<th class="table-header hidden lg:table-cell">Created</th>
|
||||||
@ -473,6 +502,13 @@
|
|||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
|
<td class="hidden px-5 py-3.5 md:table-cell">
|
||||||
|
{#if tmpl.team_id === PLATFORM_TEAM_ID}
|
||||||
|
<span class="text-meta text-[var(--color-text-muted)]">Platform</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-meta text-[var(--color-text-secondary)]">{teamNames.get(tmpl.team_id) ?? tmpl.team_id}</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
<td class="hidden px-5 py-3.5 md:table-cell">
|
<td class="hidden px-5 py-3.5 md:table-cell">
|
||||||
{#if tmpl.vcpus && tmpl.memory_mb}
|
{#if tmpl.vcpus && tmpl.memory_mb}
|
||||||
<span class="font-mono text-meta tabular-nums text-[var(--color-text-secondary)]">
|
<span class="font-mono text-meta tabular-nums text-[var(--color-text-secondary)]">
|
||||||
@ -495,7 +531,11 @@
|
|||||||
<td class="px-5 py-3.5 text-right">
|
<td class="px-5 py-3.5 text-right">
|
||||||
<button
|
<button
|
||||||
onclick={() => { deleteTarget = tmpl; deleteError = null; }}
|
onclick={() => { deleteTarget = tmpl; deleteError = null; }}
|
||||||
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta text-[var(--color-text-tertiary)] transition-all duration-150 hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]"
|
disabled={!canDeleteTemplate(tmpl)}
|
||||||
|
title={tmpl.name === 'minimal' ? 'The minimal template cannot be deleted' : !canDeleteTemplate(tmpl) ? 'Cannot delete templates owned by other teams' : undefined}
|
||||||
|
class="rounded-[var(--radius-button)] px-3 py-1.5 text-meta transition-all duration-150 {canDeleteTemplate(tmpl)
|
||||||
|
? 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-red)]/10 hover:text-[var(--color-red)]'
|
||||||
|
: 'text-[var(--color-text-muted)] cursor-not-allowed opacity-40'}"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -5,8 +5,10 @@
|
|||||||
import {
|
import {
|
||||||
listAdminUsers,
|
listAdminUsers,
|
||||||
setUserActive,
|
setUserActive,
|
||||||
|
setUserAdmin,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
} from '$lib/api/admin-users';
|
} from '$lib/api/admin-users';
|
||||||
|
import { auth } from '$lib/auth.svelte';
|
||||||
|
|
||||||
// Data state
|
// Data state
|
||||||
let users = $state<AdminUser[]>([]);
|
let users = $state<AdminUser[]>([]);
|
||||||
@ -22,6 +24,11 @@
|
|||||||
// Toggle state
|
// Toggle state
|
||||||
let togglingId = $state<string | null>(null);
|
let togglingId = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Admin dialog state
|
||||||
|
let adminTarget = $state<AdminUser | null>(null);
|
||||||
|
let togglingAdmin = $state(false);
|
||||||
|
let adminError = $state<string | null>(null);
|
||||||
|
|
||||||
async function fetchUsers(page: number = 1) {
|
async function fetchUsers(page: number = 1) {
|
||||||
const wasEmpty = users.length === 0;
|
const wasEmpty = users.length === 0;
|
||||||
if (wasEmpty) loading = true;
|
if (wasEmpty) loading = true;
|
||||||
@ -56,6 +63,23 @@
|
|||||||
togglingId = null;
|
togglingId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleConfirmAdminToggle() {
|
||||||
|
if (!adminTarget) return;
|
||||||
|
togglingAdmin = true;
|
||||||
|
adminError = null;
|
||||||
|
const target = adminTarget;
|
||||||
|
const newAdmin = !target.is_admin;
|
||||||
|
const result = await setUserAdmin(target.id, newAdmin);
|
||||||
|
if (result.ok) {
|
||||||
|
adminTarget = null;
|
||||||
|
target.is_admin = newAdmin;
|
||||||
|
toast.success(`${target.email} ${newAdmin ? 'granted' : 'revoked'} admin`);
|
||||||
|
} else {
|
||||||
|
adminError = result.error;
|
||||||
|
}
|
||||||
|
togglingAdmin = false;
|
||||||
|
}
|
||||||
|
|
||||||
function goToPage(page: number) {
|
function goToPage(page: number) {
|
||||||
if (page < 1 || page > totalPages) return;
|
if (page < 1 || page > totalPages) return;
|
||||||
fetchUsers(page);
|
fetchUsers(page);
|
||||||
@ -222,8 +246,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Role -->
|
<!-- Role -->
|
||||||
<div class="px-5 py-4">
|
<div class="flex items-center px-5 py-4">
|
||||||
<span class="text-ui text-[var(--color-text-secondary)]">{user.is_admin ? 'Admin' : 'User'}</span>
|
<button
|
||||||
|
onclick={() => { adminError = null; adminTarget = user; }}
|
||||||
|
disabled={user.status !== 'active'}
|
||||||
|
aria-label="{user.is_admin ? 'Revoke admin for' : 'Grant admin to'} {user.name || user.email}"
|
||||||
|
class="rounded-[var(--radius-button)] border px-3 py-1.5 text-meta font-medium transition-all duration-150 disabled:opacity-50
|
||||||
|
{user.is_admin
|
||||||
|
? 'border-[var(--color-amber)]/30 bg-[var(--color-amber)]/10 text-[var(--color-amber)] hover:bg-[var(--color-amber)]/20 hover:border-[var(--color-amber)]/50'
|
||||||
|
: 'border-[var(--color-border)] text-[var(--color-text-tertiary)] hover:border-[var(--color-border-mid)] hover:text-[var(--color-text-secondary)]'}"
|
||||||
|
>
|
||||||
|
{user.is_admin ? 'Admin' : 'User'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Joined -->
|
<!-- Joined -->
|
||||||
@ -292,3 +326,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Admin confirmation dialog -->
|
||||||
|
{#if adminTarget}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-black/60"
|
||||||
|
onclick={() => { if (!togglingAdmin) adminTarget = null; }}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Escape' && !togglingAdmin) adminTarget = null; }}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="relative w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]"
|
||||||
|
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
|
||||||
|
>
|
||||||
|
<div class="p-6">
|
||||||
|
<h2 class="font-serif text-heading leading-tight text-[var(--color-text-bright)]">
|
||||||
|
{adminTarget.is_admin ? 'Revoke Admin' : 'Grant Admin'}
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1.5 text-ui text-[var(--color-text-tertiary)]">
|
||||||
|
{adminTarget.is_admin ? 'Remove admin access from' : 'Grant admin access to'}
|
||||||
|
<code class="rounded bg-[var(--color-bg-4)] px-1.5 py-0.5 font-mono text-[0.8rem] text-[var(--color-text-primary)]">{adminTarget.email}</code>.
|
||||||
|
{adminTarget.is_admin
|
||||||
|
? 'They will lose access to the admin panel immediately.'
|
||||||
|
: 'They will be able to manage all platform resources.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if adminTarget.is_admin && adminTarget.id === auth.userId}
|
||||||
|
<div class="mt-3 flex items-start gap-2.5 rounded-[var(--radius-input)] border border-[var(--color-amber)]/30 bg-[var(--color-amber)]/5 px-3 py-2.5">
|
||||||
|
<svg class="mt-0.5 shrink-0 text-[var(--color-amber)]" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-meta text-[var(--color-amber)]">
|
||||||
|
You are removing your own admin access. You will lose access to this panel.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if adminError}
|
||||||
|
<div class="mt-3 rounded-[var(--radius-input)] border border-[var(--color-red)]/30 bg-[var(--color-red)]/5 px-3 py-2 text-meta text-[var(--color-red)]">
|
||||||
|
{adminError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onclick={() => (adminTarget = null)}
|
||||||
|
disabled={togglingAdmin}
|
||||||
|
class="rounded-[var(--radius-button)] border border-[var(--color-border)] px-4 py-2 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-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={handleConfirmAdminToggle}
|
||||||
|
disabled={togglingAdmin}
|
||||||
|
class="flex items-center gap-2 rounded-[var(--radius-button)] px-5 py-2 text-ui font-semibold text-white transition-all duration-150 hover:brightness-110 hover:-translate-y-px active:translate-y-0 disabled:opacity-50 disabled:hover:translate-y-0
|
||||||
|
{adminTarget.is_admin ? 'bg-[var(--color-red)]' : 'bg-[var(--color-accent)]'}"
|
||||||
|
>
|
||||||
|
{#if togglingAdmin}
|
||||||
|
<svg class="animate-spin" width="13" height="13" 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>
|
||||||
|
{adminTarget.is_admin ? 'Revoking...' : 'Granting...'}
|
||||||
|
{:else}
|
||||||
|
{adminTarget.is_admin ? 'Revoke admin' : 'Grant admin'}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@ -120,6 +120,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeCapsuleData(incoming: Capsule[]) {
|
||||||
|
const existingMap = new Map(capsules.map((c) => [c.id, c]));
|
||||||
|
const merged: Capsule[] = [];
|
||||||
|
for (const fresh of incoming) {
|
||||||
|
const existing = existingMap.get(fresh.id);
|
||||||
|
if (existing) {
|
||||||
|
for (const key of Object.keys(fresh) as (keyof Capsule)[]) {
|
||||||
|
if (existing[key] !== fresh[key]) {
|
||||||
|
(existing as any)[key] = fresh[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.push(existing);
|
||||||
|
} else {
|
||||||
|
merged.push(fresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
capsules = merged;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCapsules(manual = false) {
|
async function fetchCapsules(manual = false) {
|
||||||
const wasEmpty = capsules.length === 0;
|
const wasEmpty = capsules.length === 0;
|
||||||
if (wasEmpty) loading = true;
|
if (wasEmpty) loading = true;
|
||||||
@ -131,7 +150,11 @@
|
|||||||
|
|
||||||
const result = await listCapsules();
|
const result = await listCapsules();
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
capsules = result.data;
|
if (wasEmpty) {
|
||||||
|
capsules = result.data;
|
||||||
|
} else {
|
||||||
|
mergeCapsuleData(result.data);
|
||||||
|
}
|
||||||
error = null;
|
error = null;
|
||||||
} else {
|
} else {
|
||||||
error = result.error;
|
error = result.error;
|
||||||
|
|||||||
@ -333,6 +333,7 @@
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
lastDataKey = '';
|
||||||
updateCharts();
|
updateCharts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -376,6 +377,7 @@
|
|||||||
|
|
||||||
if (!metricsAvailable) return;
|
if (!metricsAvailable) return;
|
||||||
|
|
||||||
|
loadMetrics();
|
||||||
const mod = await import('chart.js/auto');
|
const mod = await import('chart.js/auto');
|
||||||
ChartJS = mod.Chart;
|
ChartJS = mod.Chart;
|
||||||
|
|
||||||
|
|||||||
@ -162,3 +162,58 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUserAdmin handles PUT /v1/admin/users/{id}/admin
|
||||||
|
// Grants or revokes platform admin status. Cannot remove the last admin.
|
||||||
|
func (h *usersHandler) SetUserAdmin(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 {
|
||||||
|
Admin bool `json:"admin"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.db.GetUserByID(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.IsAdmin == req.Admin {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Admin {
|
||||||
|
if err := h.db.SetUserAdmin(r.Context(), db.SetUserAdminParams{
|
||||||
|
ID: userID,
|
||||||
|
IsAdmin: true,
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit.LogUserGrantAdmin(r.Context(), ac, userID, user.Email)
|
||||||
|
} else {
|
||||||
|
affected, err := h.db.RevokeUserAdmin(r.Context(), userID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal", "failed to update admin status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "cannot remove the last admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.audit.LogUserRevokeAdmin(r.Context(), ac, userID, user.Email)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|||||||
@ -2346,6 +2346,54 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Error"
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
|
/v1/admin/users/{id}/admin:
|
||||||
|
put:
|
||||||
|
summary: Grant or revoke platform admin
|
||||||
|
operationId: setUserAdmin
|
||||||
|
tags: [admin]
|
||||||
|
description: |
|
||||||
|
Sets the platform admin flag on a user. Cannot remove the last admin.
|
||||||
|
Requires platform admin access (JWT + is_admin).
|
||||||
|
The target user's JWT is not re-issued — their frontend will reflect the
|
||||||
|
change on next login or team switch.
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
example: "usr-a1b2c3d4"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [admin]
|
||||||
|
properties:
|
||||||
|
admin:
|
||||||
|
type: boolean
|
||||||
|
description: true to grant admin, false to revoke.
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Admin status updated
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"403":
|
||||||
|
description: Caller is not a platform admin
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Error"
|
||||||
|
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
apiKeyAuth:
|
apiKeyAuth:
|
||||||
|
|||||||
@ -269,6 +269,7 @@ func New(
|
|||||||
r.Delete("/teams/{id}", teamH.AdminDeleteTeam)
|
r.Delete("/teams/{id}", teamH.AdminDeleteTeam)
|
||||||
r.Get("/users", usersH.AdminListUsers)
|
r.Get("/users", usersH.AdminListUsers)
|
||||||
r.Put("/users/{id}/active", usersH.SetUserActive)
|
r.Put("/users/{id}/active", usersH.SetUserActive)
|
||||||
|
r.Put("/users/{id}/admin", usersH.SetUserAdmin)
|
||||||
r.Get("/audit-logs", auditH.AdminList)
|
r.Get("/audit-logs", auditH.AdminList)
|
||||||
r.Get("/templates", buildH.ListTemplates)
|
r.Get("/templates", buildH.ListTemplates)
|
||||||
r.Delete("/templates/{name}", buildH.DeleteTemplate)
|
r.Delete("/templates/{name}", buildH.DeleteTemplate)
|
||||||
|
|||||||
@ -365,6 +365,14 @@ func (l *AuditLogger) LogUserDeactivate(ctx context.Context, ac auth.AuthContext
|
|||||||
l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "deactivate", "warning", map[string]any{"email": email}))
|
l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "deactivate", "warning", map[string]any{"email": email}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *AuditLogger) LogUserGrantAdmin(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) {
|
||||||
|
l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "grant_admin", "success", map[string]any{"email": email}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AuditLogger) LogUserRevokeAdmin(ctx context.Context, ac auth.AuthContext, userID pgtype.UUID, email string) {
|
||||||
|
l.Log(ctx, newAdminEntry(ac, "user", id.FormatUserID(userID), "revoke_admin", "warning", map[string]any{"email": email}))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Team admin events (scope: admin) ---
|
// --- Team admin events (scope: admin) ---
|
||||||
|
|
||||||
func (l *AuditLogger) LogTeamSetBYOC(ctx context.Context, ac auth.AuthContext, teamID pgtype.UUID, enabled bool) {
|
func (l *AuditLogger) LogTeamSetBYOC(ctx context.Context, ac auth.AuthContext, teamID pgtype.UUID, enabled bool) {
|
||||||
|
|||||||
@ -415,6 +415,21 @@ func (q *Queries) ListUsersAdmin(ctx context.Context, arg ListUsersAdminParams)
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const revokeUserAdmin = `-- name: RevokeUserAdmin :execrows
|
||||||
|
UPDATE users u SET is_admin = false, updated_at = NOW()
|
||||||
|
WHERE u.id = $1
|
||||||
|
AND u.is_admin = true
|
||||||
|
AND (SELECT COUNT(*) FROM users WHERE is_admin = true AND status != 'deleted') > 1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) RevokeUserAdmin(ctx context.Context, id pgtype.UUID) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, revokeUserAdmin, id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), 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
|
||||||
`
|
`
|
||||||
|
|||||||
Reference in New Issue
Block a user