GuidesClient
Admin
adminApi — user management, audit-log queries, and platform invites for an admin dashboard, built on the apiRequest helper.
Admin client
adminApi covers common dashboard operations from the Admin and Audit Logs guides. Add any remaining admin route with the same apiRequest helper. The login method is public; every /admin/* method requires a logged-in user with role: "admin".
import { apiRequest } from "./client";
const API_BASE = "https://api.myapp.com";
export const adminApi = {
login: (email: string, password: string) =>
apiRequest(API_BASE, "POST", "/auth/admin/login", { email, password }),
listUsers: (params: Record<string, string | number> = {}) =>
apiRequest(API_BASE, "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(API_BASE, "GET", `/admin/users/count?${new URLSearchParams(params as Record<string, string>)}`),
getUser: (userId: string) => apiRequest(API_BASE, "GET", `/admin/users/${userId}`),
createUser: (input: { email: string; password: string; name: string; role?: "user" | "admin" }) =>
apiRequest(API_BASE, "POST", "/admin/users", input),
updateRole: (userId: string, role: "user" | "admin") =>
apiRequest(API_BASE, "PATCH", `/admin/users/${userId}/role`, { role }),
ban: (userId: string) => apiRequest(API_BASE, "PATCH", `/admin/users/${userId}/ban`),
unban: (userId: string) => apiRequest(API_BASE, "PATCH", `/admin/users/${userId}/unban`),
deleteUser: (userId: string) => apiRequest(API_BASE, "DELETE", `/admin/users/${userId}`),
listUserSessions: (userId: string, offset = 0, limit = 20) =>
apiRequest(API_BASE, "GET", `/admin/users/${userId}/sessions?offset=${offset}&limit=${limit}`),
revokeUserSession: (userId: string, sessionId: string) =>
apiRequest(API_BASE, "DELETE", `/admin/users/${userId}/sessions/${sessionId}`),
revokeUserSessions: (userId: string) =>
apiRequest(API_BASE, "DELETE", `/admin/users/${userId}/sessions`),
// --- Stats and activity ---
getStats: () => apiRequest(API_BASE, "GET", "/admin/stats"),
getRegistrationTrend: (from: string, to: string) =>
apiRequest(API_BASE, "GET", `/admin/stats/registrations?from=${from}&to=${to}`),
getLoginActivity: (from: string, to: string, userId?: string) =>
apiRequest(
API_BASE, "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(API_BASE, "GET", `/admin/audit-logs?${new URLSearchParams(params as Record<string, string>)}`),
countAuditLogs: (params: Record<string, string | number> = {}) =>
apiRequest(API_BASE, "GET", `/admin/audit-logs/count?${new URLSearchParams(params as Record<string, string>)}`),
listUserAuditLogs: (userId: string, params: Record<string, string | number> = {}) =>
apiRequest(API_BASE, "GET", `/admin/users/${userId}/audit-logs?${new URLSearchParams(params as Record<string, string>)}`),
countUserAuditLogs: (userId: string, params: Record<string, string | number> = {}) =>
apiRequest(API_BASE, "GET", `/admin/users/${userId}/audit-logs/count?${new URLSearchParams(params as Record<string, string>)}`),
// --- Platform invites — requires RegistrationConfig.EnableInvite ---
createInvite: (email: string) => apiRequest(API_BASE, "POST", "/admin/invites", { email }),
listInvites: (params: Record<string, string | number> = {}) =>
apiRequest(API_BASE, "GET", `/admin/invites?${new URLSearchParams(params as Record<string, string>)}`),
countInvites: (params: Record<string, string | number> = {}) =>
apiRequest(API_BASE, "GET", `/admin/invites/count?${new URLSearchParams(params as Record<string, string>)}`),
revokeInvite: (inviteId: string) => apiRequest(API_BASE, "DELETE", `/admin/invites/${inviteId}`),
resendInvite: (inviteId: string) => apiRequest(API_BASE, "POST", `/admin/invites/${inviteId}/resend`),
deleteInvite: (inviteId: string) => apiRequest(API_BASE, "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,login.locked" });
// 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={severity variant(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
- Setup — the
apiRequesthelper this is built on - Admin — full request/response/error reference
- Audit Logs — event types and filter params