Admin
adminApi — user management, audit-log queries, and platform invites for an admin dashboard, built on the apiRequest helper.
Admin client
Use the admin fetch wrapper, including for 2FA, refresh, and logout. It selects the independent admin context. Keep delegated operators who sign in through ordinary app login on the app client wrapper.
adminApi covers common dashboard operations from the Admin and
Audit Logs guides. The examples below use disabled-mode
fixed admin/user roles, which remain supported when app permissions are
disabled. When app permissions are enabled, use GET /auth/access to render
the current isFullAccess and permissionKeys, and use the enabled-mode
appRoleId payloads/filters described in the
app permission guide. Delegated operators sign in
through ordinary login. Every backend action still checks current access;
frontend flags are informational.
For enabled-mode authorization management, follow the app roles and permissions client. It provides typed wrappers and shadcn examples for every action, including role assignment and permission-aware controls for delegated operators.
// Use the admin-specific wrapper from Admin Client Integration.
// It adds X-GoAuth-Session: admin to bootstrap and every API call.
import { apiRequest } from "./http";
export const adminApi = {
login: (email: string, password: string) =>
apiRequest("POST", "/auth/admin/login", { email, password }),
listUsers: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/users?${new URLSearchParams(params as Record<string, string>)}`),
// The list response has no `total`. Fetch the count separately, keyed on
// the filter params only (drop offset/limit) so paging doesn't re-run it.
countUsers: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/users/count?${new URLSearchParams(params as Record<string, string>)}`),
getUser: (userId: string) => apiRequest("GET", `/admin/users/${userId}`),
createUser: (input: { email: string; password: string; name: string; role?: "user" | "admin" }) =>
apiRequest("POST", "/admin/users", input),
updateRole: (userId: string, role: "user" | "admin") =>
apiRequest("PATCH", `/admin/users/${userId}/role`, { role }),
ban: (userId: string) => apiRequest("PATCH", `/admin/users/${userId}/ban`),
unban: (userId: string) => apiRequest("PATCH", `/admin/users/${userId}/unban`),
deleteUser: (userId: string) => apiRequest("DELETE", `/admin/users/${userId}`),
listUserSessions: (userId: string, offset = 0, limit = 20) =>
apiRequest("GET", `/admin/users/${userId}/sessions?offset=${offset}&limit=${limit}`),
revokeUserSession: (userId: string, sessionId: string) =>
apiRequest("DELETE", `/admin/users/${userId}/sessions/${sessionId}`),
revokeUserSessions: (userId: string) =>
apiRequest("DELETE", `/admin/users/${userId}/sessions`),
// --- Stats and activity ---
getStats: () => apiRequest("GET", "/admin/stats"),
getRegistrationTrend: (from: string, to: string) =>
apiRequest("GET", `/admin/stats/registrations?from=${from}&to=${to}`),
getLoginActivity: (from: string, to: string, userId?: string) =>
apiRequest(
"GET",
`/admin/stats/logins?from=${from}&to=${to}${userId ? `&userId=${userId}` : ""}`
),
// --- Audit logs — requires WithAudit(AuditConfig{Enabled: true}) server-side ---
listAuditLogs: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/audit-logs?${new URLSearchParams(params as Record<string, string>)}`),
countAuditLogs: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/audit-logs/count?${new URLSearchParams(params as Record<string, string>)}`),
listUserAuditLogs: (userId: string, params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/users/${userId}/audit-logs?${new URLSearchParams(params as Record<string, string>)}`),
countUserAuditLogs: (userId: string, params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/users/${userId}/audit-logs/count?${new URLSearchParams(params as Record<string, string>)}`),
// --- Platform invites — requires RegistrationConfig.InviteSignup ---
createInvite: (email: string) => apiRequest("POST", "/admin/invites", { email }),
listInvites: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/invites?${new URLSearchParams(params as Record<string, string>)}`),
countInvites: (params: Record<string, string | number> = {}) =>
apiRequest("GET", `/admin/invites/count?${new URLSearchParams(params as Record<string, string>)}`),
revokeInvite: (inviteId: string) => apiRequest("DELETE", `/admin/invites/${inviteId}`),
resendInvite: (inviteId: string) => apiRequest("POST", `/admin/invites/${inviteId}/resend`),
deleteInvite: (inviteId: string) => apiRequest("DELETE", `/admin/invites/${inviteId}/hard`),
};Using it
const { users } = await adminApi.listUsers({ limit: 20, search: "ada" });
const { count } = await adminApi.countUsers({ search: "ada" }); // total for the pagertry {
await adminApi.updateRole(userId, "user");
} catch (err) {
// last_admin — see the Admin guide
showError(err.message ?? err.error);
}Stats and activity
const stats = await adminApi.getStats();
const { registrations } = await adminApi.getRegistrationTrend("2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z");
const { logins } = await adminApi.getLoginActivity("2026-07-01T00:00:00Z", "2026-08-01T00:00:00Z"); // global
const { logins: userLogins } = await adminApi.getLoginActivity(from, to, userId); // one user's heatmapAudit logs
listAuditLogs() with no filters returns everything in the audit log, paginated — it's never a 404, even if audit logging was never enabled server-side (that just means the table is empty).
const { events } = await adminApi.listAuditLogs({ event_type: "login.failed", limit: 50 });
const { count } = await adminApi.countAuditLogs({ event_type: "login.failed" });
// event_type takes a comma-separated list — matches any of them
const failures = await adminApi.listAuditLogs({ event_type: "login.failed,admin.login.failed" });
// actorEmail/targetEmail resolve to a user ID server-side — no need to
// already know the UUID. 404 user_not_found if no user has that email.
const byActor = await adminApi.listAuditLogs({ actorEmail: "ada@example.com" });
// deviceType filters on the parsed user agent: mobile, desktop, tablet, bot
const mobileLogins = await adminApi.listAuditLogs({ event_type: "login.success", deviceType: "mobile" });Audit logs data table
// components/audit-logs-table.tsx
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { adminApi } from "../lib/api/admin";
interface AuditEvent {
id: string;
type: string;
severity: "info" | "warning" | "error" | "critical";
success: boolean;
actorId: string | null;
actorEmail: string | null;
targetUserId: string | null;
targetEmail: string | null;
ip: string | null;
userAgent: string | null;
metadata: Record<string, unknown>;
createdAt: string;
}
export function AuditLogsTable() {
const [events, setEvents] = useState<AuditEvent[]>([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [filters, setFilters] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const limit = 25;
useEffect(() => { loadEvents(); }, [offset, filters]);
async function loadEvents() {
setLoading(true);
setError(null);
try {
const params = { ...filters, limit, offset };
const result = await adminApi.listAuditLogs(params);
setEvents(result.events);
const countResult = await adminApi.countAuditLogs(filters);
setTotal(countResult.count);
} catch (err: any) {
setError(err.message ?? err.error);
} finally {
setLoading(false);
}
}
function severityVariant(severity: string) {
if (severity === "critical") return "destructive";
if (severity === "error") return "destructive";
if (severity === "warning") return "secondary";
return "outline";
}
return (
<Card>
<CardHeader>
<CardTitle>Audit log ({total})</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-wrap gap-2">
<Input
placeholder="Search events, IP, user agent..."
className="flex-1 min-w-[200px]"
onChange={(e) => { setFilters({ ...filters, search: e.target.value }); setOffset(0); }}
/>
<Input
placeholder="Event type (e.g. login.failed)"
className="w-48"
onChange={(e) => { setFilters({ ...filters, event_type: e.target.value }); setOffset(0); }}
/>
<Input
placeholder="Actor email"
className="w-48"
onChange={(e) => { setFilters({ ...filters, actorEmail: e.target.value }); setOffset(0); }}
/>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : events.length === 0 ? (
<p className="text-sm text-muted-foreground">No audit events match these filters.</p>
) : (
<div className="space-y-2">
{events.map((event) => (
<div key={event.id} className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="font-medium truncate">{event.type}</p>
<Badge variant={event.success ? "outline" : "destructive"}>
{event.success ? "Success" : "Failed"}
</Badge>
{event.severity !== "info" && (
<Badge variant={severityVariant(event.severity)}>
{event.severity}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground truncate">
{event.actorEmail ?? event.actorId ?? "—"}
{event.targetEmail ? ` → ${event.targetEmail}` : ""}
{event.ip ? ` · ${event.ip}` : ""}
{` · ${new Date(event.createdAt).toLocaleString()}`}
</p>
</div>
</div>
))}
</div>
)}
<div className="flex justify-between">
<Button variant="outline" size="sm" disabled={offset === 0} onClick={() => setOffset(offset - limit)}>
Previous
</Button>
<span className="text-sm text-muted-foreground">
{offset + 1}–{Math.min(offset + limit, total)} of {total}
</span>
<Button variant="outline" size="sm" disabled={offset + limit >= total} onClick={() => setOffset(offset + limit)}>
Next
</Button>
</div>
</CardContent>
</Card>
);
}Per-user audit logs
For a user detail page, scope the query to one user:
const { events } = await adminApi.listUserAuditLogs(userId, { event_type: "login.failed" });
const { count } = await adminApi.countUserAuditLogs(userId);Platform invites
const invite = await adminApi.createInvite("ada@example.com");
const { invites } = await adminApi.listInvites({ status: "pending" });
const { count } = await adminApi.countInvites({ status: "pending" });
await adminApi.resendInvite(invite.id); // issues a fresh code, invalidates the old oneNext
- Admin wrapper — the
apiRequesthelper this is built on - Admin — full request/response/error reference
- Audit Logs — event types and filter params