Dashboard authenticates: token prompt + bearer on REST/WS/recordings

The UI never sent the bearer token, so with API_TOKEN set every data
call 401'd and /ws/events was rejected pre-accept (the 403s in the
uvicorn log). The API client now keeps the token in localStorage,
attaches Authorization to every request, prompts once on a 401 and
retries, and appends ?token= to the WebSocket connect — the page's
reconnect loop picks the token up after the first prompt.

require_token also accepts a ?token= query parameter (same convention
as the WebSocket) because <audio> elements fetching recordings can't
set headers; recordingUrl() rides the token there.

The dashboard header's status call moved to a new authenticated
GET /api/v1/status — its old source was the JSON root endpoint that
the dashboard itself replaced at /.

Two new auth tests (query-param accepted / wrong query-param 401);
dashboard rebuilt. Verified live: WS rejected without token and
connected with ?token=, status 200, ?token=wrong 401.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 06:21:55 -04:00
parent dff21f7d5c
commit e7c84885d9
4 changed files with 100 additions and 17 deletions

View File

@@ -9,14 +9,50 @@ import type {
TranscriptRow,
} from './types';
// ---------------------------------------------------------------
// Bearer token — one static API_TOKEN shared with REST/WS/MCP.
// Kept in localStorage; a 401 prompts once and retries.
// ---------------------------------------------------------------
const TOKEN_KEY = 'hold-slayer-token';
function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? '';
}
export function setToken(value: string): void {
localStorage.setItem(TOKEN_KEY, value);
}
function withAuth(init: RequestInit): RequestInit {
const token = getToken();
if (!token) return init;
return {
...init,
headers: { ...(init.headers ?? {}), Authorization: `Bearer ${token}` },
};
}
async function request(path: string, init: RequestInit = {}): Promise<Response> {
let res = await fetch(path, withAuth(init));
if (res.status === 401) {
const supplied = window.prompt('Hold Slayer API token (API_TOKEN in .env):');
if (supplied !== null && supplied.trim()) {
setToken(supplied.trim());
res = await fetch(path, withAuth(init));
}
}
return res;
}
async function get<T>(path: string): Promise<T> {
const res = await fetch(path);
const res = await request(path);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
export async function fetchGatewayStatus(): Promise<GatewayStatus> {
return get<GatewayStatus>('/');
return get<GatewayStatus>('/api/v1/status');
}
export async function fetchHealth(): Promise<HealthStatus> {
@@ -32,7 +68,7 @@ export async function fetchDevices(): Promise<DeviceStatus[]> {
}
export async function hangupCall(callId: string): Promise<void> {
const res = await fetch(`/api/v1/calls/${callId}/hangup`, { method: 'POST' });
const res = await request(`/api/v1/calls/${callId}/hangup`, { method: 'POST' });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
@@ -53,7 +89,11 @@ export async function fetchTranscript(callId: string): Promise<TranscriptRow[]>
}
export function recordingUrl(callId: string): string {
return `/api/v1/calls/${callId}/recording`;
// <audio> can't send headers, so the token rides as a query param
// (accepted server-side alongside the Authorization header).
const token = getToken();
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
return `/api/v1/calls/${callId}/recording${suffix}`;
}
export async function fetchRoutingRules(): Promise<RoutingRule[]> {
@@ -61,7 +101,7 @@ export async function fetchRoutingRules(): Promise<RoutingRule[]> {
}
export async function createRoutingRule(rule: Partial<RoutingRule>): Promise<RoutingRule> {
const res = await fetch('/api/v1/routing/rules', {
const res = await request('/api/v1/routing/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule),
@@ -74,7 +114,7 @@ export async function updateRoutingRule(
ruleId: string,
patch: Partial<RoutingRule>,
): Promise<RoutingRule> {
const res = await fetch(`/api/v1/routing/rules/${ruleId}`, {
const res = await request(`/api/v1/routing/rules/${ruleId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
@@ -84,12 +124,12 @@ export async function updateRoutingRule(
}
export async function deleteRoutingRule(ruleId: string): Promise<void> {
const res = await fetch(`/api/v1/routing/rules/${ruleId}`, { method: 'DELETE' });
const res = await request(`/api/v1/routing/rules/${ruleId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
}
export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<void> {
const res = await fetch(`/api/v1/routing/devices/${deviceId}/dnd`, {
const res = await request(`/api/v1/routing/devices/${deviceId}/dnd`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
@@ -102,7 +142,9 @@ export function connectEventStream(
onClose: () => void,
): () => void {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws/events`);
const token = getToken();
const suffix = token ? `?token=${encodeURIComponent(token)}` : '';
const ws = new WebSocket(`${proto}//${location.host}/ws/events${suffix}`);
ws.onmessage = (msg) => {
try {