Merge pull request 'Enhanced frontend ux' (#42) from enhance/frontend into dev
Reviewed-on: #42
This commit is contained in:
@ -22,6 +22,12 @@ RETURNING *;
|
||||
-- name: SetUserAdmin :exec
|
||||
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
|
||||
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.
|
||||
///
|
||||
/// In Rust there is no GC dance. We just:
|
||||
/// 1. Stop port subsystem
|
||||
/// 2. Close idle connections via conntracker
|
||||
/// 3. Set needs_restore flag
|
||||
/// 1. Drop page cache to shrink snapshot size
|
||||
/// 2. Stop port subsystem
|
||||
/// 3. Close idle connections via conntracker
|
||||
/// 4. Set needs_restore flag
|
||||
pub async fn post_snapshot_prepare(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
// Block memory reclaimer before anything else — prevents drop_caches
|
||||
// from running mid-freeze which would corrupt kernel page table state.
|
||||
// Drop page cache BEFORE blocking the reclaimer — avoids snapshotting
|
||||
// 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);
|
||||
|
||||
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>> {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -233,6 +234,7 @@
|
||||
|
||||
onMount(async () => {
|
||||
if (!available) return;
|
||||
loadMetrics();
|
||||
const mod = await import('chart.js/auto');
|
||||
ChartJS = mod.Chart;
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import CopyButton from '$lib/components/CopyButton.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
import { formatDate, timeAgo } from '$lib/utils/format';
|
||||
import {
|
||||
listBuilds,
|
||||
@ -13,6 +14,7 @@
|
||||
type BuildLogEntry,
|
||||
type AdminTemplate
|
||||
} from '$lib/api/builds';
|
||||
import { listAdminTeams } from '$lib/api/team';
|
||||
|
||||
let activeTab = $state<'templates' | 'builds'>('templates');
|
||||
|
||||
@ -35,6 +37,9 @@
|
||||
let expandedBuildId = $state<string | null>(null);
|
||||
let expandedSteps = $state<Set<number>>(new Set());
|
||||
|
||||
// Team name lookup
|
||||
let teamNames = $state<Map<string, string>>(new Map());
|
||||
|
||||
// Delete template state
|
||||
let deleteTarget = $state<AdminTemplate | null>(null);
|
||||
let deleting = $state(false);
|
||||
@ -64,6 +69,28 @@
|
||||
let baseCount = $derived(templates.filter((t) => t.type === 'base').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() {
|
||||
templatesLoading = true;
|
||||
templatesError = null;
|
||||
@ -238,6 +265,7 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchTeamNames();
|
||||
fetchTemplates();
|
||||
fetchBuilds().then(startPolling);
|
||||
|
||||
@ -339,7 +367,7 @@
|
||||
<div class="flex-1 overflow-y-auto px-8 py-6">
|
||||
{#if activeTab === 'templates'}
|
||||
{#if templatesLoading}
|
||||
{@render skeletonRows(5, ['Name', 'Type', 'Specs', 'Size', 'Created', ''])}
|
||||
{@render skeletonRows(5, ['Name', 'Type', 'Owner', 'Specs', 'Size', 'Created', ''])}
|
||||
{: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)]">
|
||||
{templatesError}
|
||||
@ -442,6 +470,7 @@
|
||||
<tr class="border-b border-[var(--color-border)] bg-[var(--color-bg-0)]/40">
|
||||
<th class="table-header">Name</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 lg:table-cell">Size</th>
|
||||
<th class="table-header hidden lg:table-cell">Created</th>
|
||||
@ -473,6 +502,13 @@
|
||||
</span>
|
||||
{/if}
|
||||
</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">
|
||||
{#if tmpl.vcpus && tmpl.memory_mb}
|
||||
<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">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
|
||||
@ -5,8 +5,10 @@
|
||||
import {
|
||||
listAdminUsers,
|
||||
setUserActive,
|
||||
setUserAdmin,
|
||||
type AdminUser,
|
||||
} from '$lib/api/admin-users';
|
||||
import { auth } from '$lib/auth.svelte';
|
||||
|
||||
// Data state
|
||||
let users = $state<AdminUser[]>([]);
|
||||
@ -22,6 +24,11 @@
|
||||
// Toggle state
|
||||
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) {
|
||||
const wasEmpty = users.length === 0;
|
||||
if (wasEmpty) loading = true;
|
||||
@ -56,6 +63,23 @@
|
||||
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) {
|
||||
if (page < 1 || page > totalPages) return;
|
||||
fetchUsers(page);
|
||||
@ -222,8 +246,18 @@
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div class="px-5 py-4">
|
||||
<span class="text-ui text-[var(--color-text-secondary)]">{user.is_admin ? 'Admin' : 'User'}</span>
|
||||
<div class="flex items-center px-5 py-4">
|
||||
<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>
|
||||
|
||||
<!-- Joined -->
|
||||
@ -292,3 +326,72 @@
|
||||
</div>
|
||||
</footer>
|
||||
</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) {
|
||||
const wasEmpty = capsules.length === 0;
|
||||
if (wasEmpty) loading = true;
|
||||
@ -131,7 +150,11 @@
|
||||
|
||||
const result = await listCapsules();
|
||||
if (result.ok) {
|
||||
capsules = result.data;
|
||||
if (wasEmpty) {
|
||||
capsules = result.data;
|
||||
} else {
|
||||
mergeCapsuleData(result.data);
|
||||
}
|
||||
error = null;
|
||||
} else {
|
||||
error = result.error;
|
||||
|
||||
@ -333,6 +333,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
lastDataKey = '';
|
||||
updateCharts();
|
||||
}
|
||||
|
||||
@ -376,6 +377,7 @@
|
||||
|
||||
if (!metricsAvailable) return;
|
||||
|
||||
loadMetrics();
|
||||
const mod = await import('chart.js/auto');
|
||||
ChartJS = mod.Chart;
|
||||
|
||||
|
||||
@ -162,3 +162,58 @@ func (h *usersHandler) SetUserActive(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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:
|
||||
$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:
|
||||
securitySchemes:
|
||||
apiKeyAuth:
|
||||
|
||||
@ -269,6 +269,7 @@ func New(
|
||||
r.Delete("/teams/{id}", teamH.AdminDeleteTeam)
|
||||
r.Get("/users", usersH.AdminListUsers)
|
||||
r.Put("/users/{id}/active", usersH.SetUserActive)
|
||||
r.Put("/users/{id}/admin", usersH.SetUserAdmin)
|
||||
r.Get("/audit-logs", auditH.AdminList)
|
||||
r.Get("/templates", buildH.ListTemplates)
|
||||
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}))
|
||||
}
|
||||
|
||||
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) ---
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
SELECT id, email FROM users WHERE email LIKE $1 || '%' ORDER BY email LIMIT 10
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user