Files
server-26/drb-frontend/lib/c2api.ts
T
Logan CusanoandClaude Opus 5 2a1d52b7af Add a real signup path instead of the accidental one
SAAS_PLAN.md 2.2: there was no /signup page. The only self-serve path was
Google sign-in on /login, which auto-provisions a Firebase account with no
role or org claim at all - previously that meant "viewer role, full read
access" the moment the AuthProvider cookie logic (previous commit) let it
through. That's closed now regardless; this commit is the other side of it
- giving people an actual way in.

app/signup/page.tsx: email/password (createUserWithEmailAndPassword) or
Google, same visual language as /login. It only creates the Firebase
account - org naming is deliberately not on this page, so every path that
produces an account with no org (this one, and Google-via-/login) converges
on the same next screen.

app/onboarding/page.tsx: that screen. Shown to any signed-in user with no
orgId (ChromeSwitcher's redirect, previous commit), collects an org name,
calls the new c2api.signup() -> POST /auth/signup (routers/links.py,
already shipped), then refreshClaims() to force-refetch the ID token so
orgId picks up immediately and the same redirect effect sends them on to
/dashboard - no manual reload needed.

lib/c2api.ts also gained getOrg/updateOrg and the enrollment-token
mint/list/revoke calls (routers/org.py, already shipped on the backend)
and joinWaitlist (routers/waitlist.py) - none consumed yet, wired in ahead
of the settings/legal commits that use them so this stays one add per
concept rather than scattering client additions across later commits.

/login gained a "Don't have an account? Sign up" link to /signup. This is
signup plumbing, not marketing copy - pricing/plan copy (app/pricing,
lib/billing.ts) is untouched in this pass, that's a separate, still-open
decision (SAAS_PLAN.md section 6).

Typecheck: clean (tsc --noEmit via the WSL-native ~/drb-frontend copy).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:34:04 -04:00

257 lines
13 KiB
TypeScript

import { auth } from "@/lib/firebase";
const BASE = process.env.NEXT_PUBLIC_C2_URL ?? "http://localhost:8000";
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const user = auth.currentUser;
const token = user ? await user.getIdToken() : null;
const res = await fetch(`${BASE}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options?.headers as Record<string, string> | undefined),
},
});
if (!res.ok) throw new Error(`C2 API error ${res.status}: ${await res.text()}`);
if (res.status === 204) return undefined as T;
return res.json();
}
export const c2api = {
// Nodes
getNodes: () => request<unknown[]>("/nodes"),
getNode: (id: string) => request<unknown>(`/nodes/${id}`),
sendCommand: (nodeId: string, payload: object) =>
request(`/nodes/${nodeId}/command`, { method: "POST", body: JSON.stringify(payload) }),
assignSystem: (nodeId: string, systemId: string, hardwarePreset: string, ppmOverride?: number) => {
const params = new URLSearchParams({ hardware_preset: hardwarePreset });
if (ppmOverride !== undefined) params.set("ppm_override", String(ppmOverride));
return request(`/nodes/${nodeId}/config/${systemId}?${params}`, { method: "POST" });
},
ackOverride: (nodeId: string, timeoutMinutes: number = 1440) =>
request(`/nodes/${nodeId}/override/ack`, { method: "POST", body: JSON.stringify({ timeout_minutes: timeoutMinutes }) }),
resetOverride: (nodeId: string) =>
request(`/nodes/${nodeId}/override/reset`, { method: "POST" }),
updateNode: (id: string, body: { node_type?: string; enforce_override_timeout?: boolean }) =>
request(`/nodes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
// Systems
getSystems: () => request<unknown[]>("/systems"),
createSystem: (body: object) =>
request("/systems", { method: "POST", body: JSON.stringify(body) }),
updateSystem: (id: string, body: object) =>
request(`/systems/${id}`, { method: "PUT", body: JSON.stringify(body) }),
deleteSystem: (id: string) =>
request(`/systems/${id}`, { method: "DELETE" }),
// Tokens
getTokens: () => request<unknown[]>("/tokens"),
addToken: (body: { name: string; token: string }) =>
request("/tokens", { method: "POST", body: JSON.stringify(body) }),
deleteToken: (id: string) =>
request(`/tokens/${id}`, { method: "DELETE" }),
// Node approval
approveNode: (id: string) =>
request(`/nodes/${id}/approve`, { method: "POST" }),
rejectNode: (id: string) =>
request(`/nodes/${id}/reject`, { method: "POST" }),
deleteNode: (id: string) =>
request(`/nodes/${id}`, { method: "DELETE" }),
// Calls
getCall: (callId: string) => request<import("@/lib/types").CallRecord>(`/calls/${callId}`),
getCalls: (params?: Record<string, string>) => {
const qs = params ? "?" + new URLSearchParams(params).toString() : "";
return request<unknown[]>(`/calls${qs}`);
},
patchTranscript: (callId: string, transcript: string) =>
request(`/calls/${callId}/transcript`, { method: "PATCH", body: JSON.stringify({ transcript }) }),
closeStallCalls: (olderThanMinutes: number, dryRun: boolean) =>
request<{ dry_run: boolean; older_than_minutes: number; count: number; call_ids: string[] }>(`/calls/close-stale?older_than_minutes=${olderThanMinutes}&dry_run=${dryRun}`, { method: "POST" }),
// Incidents
getIncidents: (params?: { status?: string; type?: string }) => {
const qs = params ? "?" + new URLSearchParams(params as Record<string, string>).toString() : "";
return request<unknown[]>(`/incidents${qs}`);
},
getIncident: (id: string) => request<unknown>(`/incidents/${id}`),
createIncident: (body: object) =>
request("/incidents", { method: "POST", body: JSON.stringify(body) }),
updateIncident: (id: string, body: object) =>
request(`/incidents/${id}`, { method: "PUT", body: JSON.stringify(body) }),
deleteIncident: (id: string) =>
request(`/incidents/${id}`, { method: "DELETE" }),
linkCallToIncident: (incidentId: string, callId: string) =>
request(`/incidents/${incidentId}/calls/${callId}`, { method: "POST" }),
summarizeIncident: (id: string) =>
request(`/incidents/${id}/summarize`, { method: "POST" }),
// Alerts
getAlerts: (acknowledged?: boolean) => {
const qs = acknowledged !== undefined ? `?acknowledged=${acknowledged}` : "";
return request<unknown[]>(`/alerts${qs}`);
},
acknowledgeAlert: (id: string) =>
request(`/alerts/${id}/acknowledge`, { method: "POST" }),
getAlertRules: () => request<unknown[]>("/alert-rules"),
createAlertRule: (body: object) =>
request("/alert-rules", { method: "POST", body: JSON.stringify(body) }),
updateAlertRule: (id: string, body: object) =>
request(`/alert-rules/${id}`, { method: "PUT", body: JSON.stringify(body) }),
deleteAlertRule: (id: string) =>
request(`/alert-rules/${id}`, { method: "DELETE" }),
// Node key management
reissueNodeKey: (nodeId: string) =>
request(`/nodes/${nodeId}/reissue-key`, { method: "POST" }),
// Ten-codes
getTenCodes: (systemId: string) =>
request<{ ten_codes: Record<string, string> }>(`/systems/${systemId}/ten-codes`),
updateTenCodes: (systemId: string, ten_codes: Record<string, string>) =>
request(`/systems/${systemId}/ten-codes`, { method: "PUT", body: JSON.stringify({ ten_codes }) }),
// Vocabulary
getVocabulary: (systemId: string) =>
request<{ vocabulary: string[]; vocabulary_pending: { term: string; source: "induction" | "correction"; added_at: string }[]; vocabulary_bootstrapped: boolean }>(
`/systems/${systemId}/vocabulary`
),
bootstrapVocabulary: (systemId: string) =>
request<{ added: number; terms: string[] }>(`/systems/${systemId}/vocabulary/bootstrap`, { method: "POST" }),
addVocabularyTerm: (systemId: string, term: string) =>
request(`/systems/${systemId}/vocabulary/terms`, { method: "POST", body: JSON.stringify({ term }) }),
removeVocabularyTerm: (systemId: string, term: string) =>
request(`/systems/${systemId}/vocabulary/terms`, { method: "DELETE", body: JSON.stringify({ term }) }),
approvePendingTerm: (systemId: string, term: string) =>
request(`/systems/${systemId}/vocabulary/pending/approve`, { method: "POST", body: JSON.stringify({ term }) }),
dismissPendingTerm: (systemId: string, term: string) =>
request(`/systems/${systemId}/vocabulary/pending/dismiss`, { method: "POST", body: JSON.stringify({ term }) }),
// Feature flags (admin)
getFeatureFlags: () =>
request<Record<string, boolean>>("/admin/features"),
setFeatureFlags: (flags: Record<string, boolean>) =>
request<Record<string, boolean>>("/admin/features", { method: "PUT", body: JSON.stringify(flags) }),
getCorrelationDebug: (limit: number, orphanHours: number) =>
request<unknown>(`/admin/debug/correlation?limit=${limit}&orphan_hours=${orphanHours}`),
// Preferred bot token per system
setPreferredToken: (tokenId: string, systemId: string) =>
request<{ ok: boolean; preferred_for_system_id: string | null }>(`/tokens/${tokenId}/prefer/${systemId}`, { method: "PUT" }),
// Trips
getTrips: () => request<import("@/lib/types").TripRecord[]>("/trips"),
getTrip: (id: string) =>
request<import("@/lib/types").TripRecord & { events: import("@/lib/types").TripEvent[] }>(`/trips/${id}`),
createTrip: (body: object) =>
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
deleteTrip: (id: string) =>
request(`/trips/${id}`, { method: "DELETE" }),
updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
setTripVisibility: (id: string, visibility: "public" | "private") =>
request<{ visibility: string }>(`/trips/${id}/visibility`, { method: "PUT", body: JSON.stringify({ visibility }) }),
inviteToTrip: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "POST" }),
revokeInvite: (id: string, discord_user_id: string) =>
request(`/trips/${id}/invite/${discord_user_id}`, { method: "DELETE" }),
generateLinkCode: () =>
request<{ code?: string; expires_minutes?: number; already_linked?: boolean; discord_user_id?: string }>("/auth/link/generate", { method: "POST" }),
getLinkStatus: () =>
request<{ linked: boolean; discord_user_id?: string; discord_username?: string; linked_at?: string }>("/auth/link/status"),
unlinkDiscord: () =>
request("/auth/link", { method: "DELETE" }),
createTripEvent: (tripId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
updateTripEvent: (tripId: string, eventId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events/${eventId}`, { method: "PATCH", body: JSON.stringify(body) }),
deleteTripEvent: (tripId: string, eventId: string) =>
request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }),
tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) =>
request<{ reply: string; suggestions: import("@/lib/types").TripEvent[] }>(
`/trips/${tripId}/chat`,
{ method: "POST", body: JSON.stringify({ message, history }) }
),
// Places
searchPlaces: (query: string, near: string) =>
request<import("@/lib/types").PlaceResult[]>(
`/places/search?${new URLSearchParams({ query, near }).toString()}`
),
getDirections: (origin: string, destination: string) =>
request<{ duration_text: string | null; duration_seconds: number | null; distance_text: string | null }>(
`/places/directions?${new URLSearchParams({ origin, destination }).toString()}`
),
// Per-system AI flag overrides
setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) =>
request<{ ok: boolean; ai_flags: Record<string, boolean> }>(`/systems/${systemId}/ai-flags`, {
method: "PUT",
body: JSON.stringify(flags),
}),
// User management (admin only)
listUsers: () =>
request<import("@/lib/types").UserRecord[]>("/admin/users"),
createUser: (body: { email: string; role: string; display_name?: string; owned_node_ids?: string[] }) =>
request<import("@/lib/types").UserRecord & { invite_link?: string | null }>("/admin/users", {
method: "POST",
body: JSON.stringify(body),
}),
getUser: (uid: string) =>
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`),
updateUser: (uid: string, body: { role?: string; owned_node_ids?: string[]; display_name?: string }) =>
request<import("@/lib/types").UserRecord>(`/admin/users/${uid}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
disableUser: (uid: string) =>
request<{ ok: boolean }>(`/admin/users/${uid}/disable`, { method: "POST" }),
enableUser: (uid: string) =>
request<{ ok: boolean }>(`/admin/users/${uid}/enable`, { method: "POST" }),
deleteUser: (uid: string) =>
request<{ ok: boolean }>(`/admin/users/${uid}`, { method: "DELETE" }),
// Audit log (admin only)
getAuditLog: (limit = 50, offset = 0) =>
request<import("@/lib/types").AuditEntry[]>(`/admin/audit?limit=${limit}&offset=${offset}`),
// Session recording — called on each explicit sign-in
recordSession: () =>
request<{ ok: boolean }>("/auth/session", { method: "POST" }),
// Org provisioning (SAAS_PLAN.md B4) — called once from /onboarding right
// after a Firebase account exists but before it has an org_id claim.
signup: (orgName: string) =>
request<{ org_id: string; org_name: string; already_provisioned: boolean }>("/auth/signup", {
method: "POST",
body: JSON.stringify({ org_name: orgName }),
}),
// Organization profile
getOrg: () =>
request<{ org_id: string; name: string; created_at: string }>("/org"),
updateOrg: (name: string) =>
request<{ ok: boolean; name: string }>("/org", { method: "PATCH", body: JSON.stringify({ name }) }),
// Per-org enrollment tokens (SAAS_PLAN.md B2b)
listEnrollmentTokens: () =>
request<{ token_id: string; label: string; created_at: string; revoked: boolean; uses: number }[]>(
"/org/enrollment-tokens"
),
mintEnrollmentToken: (label: string) =>
request<{ token_id: string; token: string; label: string }>("/org/enrollment-tokens", {
method: "POST",
body: JSON.stringify({ label }),
}),
revokeEnrollmentToken: (tokenId: string) =>
request(`/org/enrollment-tokens/${tokenId}`, { method: "DELETE" }),
// Public waitlist — no auth, see routers/waitlist.py
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
};