feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
All checks were successful
React + Vite + TypeScript SPA: scaffold, operations-terminal design system, Bearer-token auth, and the Machines view. - Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan), Hanken Grotesk + JetBrains Mono, status-color language (online/offline/granted/pending/denied/not_required), motion with prefers-reduced-motion honored. - Auth: token in sessionStorage via ref (never React state), protected routes, 401 session teardown, admin-gated per-agent-key UI. - Machines view: data table (sticky header, keyboard-activated rows, skeleton loading, actionable empty/error states), non-blocking detail drawer, delete confirm, admin key management with copy-once reveal. - UI primitives: Modal (focus trap + inert + portal + dialogStack), Drawer, Table, Badge/StatusDot, toast, states. - Typed API client normalizing the two error-envelope shapes. Passed Code Review (no blockers), impeccable critique-and-polish, and local gates (tsc/lint/build green). Dev-only Vite proxy to :3002. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
20
dashboard/src/api/auth.ts
Normal file
20
dashboard/src/api/auth.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { http } from "./client";
|
||||
import type { LoginRequest, LoginResponse, User } from "./types";
|
||||
|
||||
/** POST /api/auth/login — exchange credentials for a JWT + user record. */
|
||||
export function login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
// skipAuthRedirect: a 401 here is "bad credentials", not "session expired".
|
||||
return http.post<LoginResponse>("/api/auth/login", credentials, {
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/auth/me — restore the current user from a stored token. */
|
||||
export function getMe(): Promise<User> {
|
||||
return http.get<User>("/api/auth/me");
|
||||
}
|
||||
|
||||
/** POST /api/auth/logout — revoke the current token server-side. */
|
||||
export function logout(): Promise<{ message: string }> {
|
||||
return http.post<{ message: string }>("/api/auth/logout");
|
||||
}
|
||||
131
dashboard/src/api/client.ts
Normal file
131
dashboard/src/api/client.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Typed fetch wrapper for the GuruConnect API.
|
||||
//
|
||||
// Responsibilities:
|
||||
// - Resolve the base URL (VITE_API_URL, default same-origin).
|
||||
// - Attach `Authorization: Bearer <token>` from a pluggable token provider.
|
||||
// - Normalize the *two* inconsistent server error envelopes into one
|
||||
// ApiError shape so callers/UI never have to branch on which one came back.
|
||||
|
||||
const BASE_URL = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
|
||||
|
||||
/** A normalized API error. `code` is present only for the structured envelope. */
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// The token lives in memory in the auth layer. We read it through a provider so
|
||||
// the client has no hard dependency on React state and stays testable.
|
||||
let tokenProvider: () => string | null = () => null;
|
||||
|
||||
export function setTokenProvider(provider: () => string | null): void {
|
||||
tokenProvider = provider;
|
||||
}
|
||||
|
||||
// Called when any request returns 401 — lets the auth layer tear down session
|
||||
// state and bounce to /login. Set by AuthProvider.
|
||||
let onUnauthorized: (() => void) | null = null;
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||
onUnauthorized = handler;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
// Suppress the global 401 handler (used by the login call itself).
|
||||
skipAuthRedirect?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** The server's two error envelopes, unioned. We extract a message from either. */
|
||||
interface ErrorEnvelope {
|
||||
error?: string;
|
||||
detail?: string;
|
||||
error_code?: string;
|
||||
status_code?: number;
|
||||
}
|
||||
|
||||
function buildUrl(path: string): string {
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) return path;
|
||||
return `${BASE_URL}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
async function extractError(res: Response): Promise<ApiError> {
|
||||
let message = `Request failed (${res.status})`;
|
||||
let code: string | undefined;
|
||||
|
||||
const raw = await res.text();
|
||||
if (raw) {
|
||||
try {
|
||||
const env = JSON.parse(raw) as ErrorEnvelope;
|
||||
// Handle BOTH envelopes: `{error}` and `{detail, error_code, status_code}`.
|
||||
const msg = env.detail ?? env.error;
|
||||
if (typeof msg === "string" && msg.length > 0) message = msg;
|
||||
if (typeof env.error_code === "string") code = env.error_code;
|
||||
} catch {
|
||||
// Non-JSON body (e.g. the machines routes return plain &'static str on
|
||||
// error). Use the trimmed text as the message if it looks sane.
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed && trimmed.length < 300) message = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return new ApiError(message, res.status, code);
|
||||
}
|
||||
|
||||
async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
const token = tokenProvider();
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(opts.body);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(buildUrl(path), {
|
||||
method: opts.method ?? "GET",
|
||||
headers,
|
||||
body,
|
||||
signal: opts.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
throw new ApiError("Network error — could not reach the server.", 0);
|
||||
}
|
||||
|
||||
if (res.status === 401 && !opts.skipAuthRedirect) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw await extractError(res);
|
||||
}
|
||||
|
||||
// 204 No Content / empty body.
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(path: string, signal?: AbortSignal) =>
|
||||
request<T>(path, { method: "GET", signal }),
|
||||
post: <T>(path: string, body?: unknown, opts?: Partial<RequestOptions>) =>
|
||||
request<T>(path, { method: "POST", body, ...opts }),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, { method: "PUT", body }),
|
||||
del: <T>(path: string) => request<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
5
dashboard/src/api/index.ts
Normal file
5
dashboard/src/api/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./types";
|
||||
export { ApiError, http, setTokenProvider, setUnauthorizedHandler } from "./client";
|
||||
export * as authApi from "./auth";
|
||||
export * as machinesApi from "./machines";
|
||||
export * as stubsApi from "./stubs";
|
||||
73
dashboard/src/api/machines.ts
Normal file
73
dashboard/src/api/machines.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { http } from "./client";
|
||||
import type {
|
||||
CreatedKey,
|
||||
DeleteMachineParams,
|
||||
DeleteMachineResponse,
|
||||
KeyMetadata,
|
||||
Machine,
|
||||
MachineHistory,
|
||||
} from "./types";
|
||||
|
||||
/** GET /api/machines — the real machines endpoint (NOT /api/sessions). */
|
||||
export function listMachines(signal?: AbortSignal): Promise<Machine[]> {
|
||||
return http.get<Machine[]>("/api/machines", signal);
|
||||
}
|
||||
|
||||
/** GET /api/machines/:agent_id — single machine. */
|
||||
export function getMachine(agentId: string): Promise<Machine> {
|
||||
return http.get<Machine>(`/api/machines/${encodeURIComponent(agentId)}`);
|
||||
}
|
||||
|
||||
/** GET /api/machines/:agent_id/history — past sessions + events. */
|
||||
export function getMachineHistory(
|
||||
agentId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MachineHistory> {
|
||||
return http.get<MachineHistory>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}/history`,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/machines/:agent_id — remove a machine, optionally uninstall/export. */
|
||||
export function deleteMachine(
|
||||
agentId: string,
|
||||
params: DeleteMachineParams = {},
|
||||
): Promise<DeleteMachineResponse> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.uninstall) qs.set("uninstall", "true");
|
||||
if (params.export) qs.set("export", "true");
|
||||
const suffix = qs.toString() ? `?${qs.toString()}` : "";
|
||||
return http.del<DeleteMachineResponse>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}${suffix}`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Admin: per-agent keys --------------------------------------------------
|
||||
|
||||
/** GET /api/machines/:agent_id/keys — list key metadata (admin only). */
|
||||
export function listMachineKeys(agentId: string): Promise<KeyMetadata[]> {
|
||||
return http.get<KeyMetadata[]>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}/keys`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/machines/:agent_id/keys — mint a new per-agent key (admin only).
|
||||
* The plaintext `key` is returned ONCE in the response — never again.
|
||||
*/
|
||||
export function createMachineKey(agentId: string): Promise<CreatedKey> {
|
||||
return http.post<CreatedKey>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}/keys`,
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /api/machines/:agent_id/keys/:key_id — revoke a key (admin only). */
|
||||
export function revokeMachineKey(
|
||||
agentId: string,
|
||||
keyId: string,
|
||||
): Promise<void> {
|
||||
return http.del<void>(
|
||||
`/api/machines/${encodeURIComponent(agentId)}/keys/${encodeURIComponent(keyId)}`,
|
||||
);
|
||||
}
|
||||
23
dashboard/src/api/stubs.ts
Normal file
23
dashboard/src/api/stubs.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Scaffolds for later passes. These endpoints exist on the server but their
|
||||
// views (Sessions, Codes, Users) are out of scope for pass 1. Typed signatures
|
||||
// are stubbed here so the API surface is discoverable and future passes can
|
||||
// flesh out the response interfaces against the Rust source.
|
||||
//
|
||||
// Intentionally minimal: do NOT build UI against these yet.
|
||||
|
||||
import { http } from "./client";
|
||||
|
||||
/** GET /api/sessions — active/historical sessions. Pass 2. */
|
||||
export function listSessions(signal?: AbortSignal): Promise<unknown[]> {
|
||||
return http.get<unknown[]>("/api/sessions", signal);
|
||||
}
|
||||
|
||||
/** GET /api/codes — one-time support codes. Pass 2. */
|
||||
export function listCodes(signal?: AbortSignal): Promise<unknown[]> {
|
||||
return http.get<unknown[]>("/api/codes", signal);
|
||||
}
|
||||
|
||||
/** GET /api/users — dashboard users (admin). Pass 2. */
|
||||
export function listUsers(signal?: AbortSignal): Promise<unknown[]> {
|
||||
return http.get<unknown[]>("/api/users", signal);
|
||||
}
|
||||
115
dashboard/src/api/types.ts
Normal file
115
dashboard/src/api/types.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Typed mirrors of the GuruConnect server API responses.
|
||||
// Shapes match server/src/api/*.rs exactly. Keep in sync with the Rust source
|
||||
// of truth — these are hand-maintained, not generated.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Role = "admin" | "operator" | "viewer";
|
||||
|
||||
export type Permission =
|
||||
| "view"
|
||||
| "control"
|
||||
| "transfer"
|
||||
| "manage_users"
|
||||
| "manage_clients";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
// role/permission come from the server as plain strings; widen defensively.
|
||||
role: Role | string;
|
||||
permissions: (Permission | string)[];
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Machines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MachineStatus = "online" | "offline";
|
||||
|
||||
export interface Machine {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
hostname: string;
|
||||
os_version: string | null;
|
||||
is_elevated: boolean;
|
||||
is_persistent: boolean;
|
||||
first_seen: string; // RFC3339
|
||||
last_seen: string; // RFC3339
|
||||
status: MachineStatus | string;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
duration_secs: number | null;
|
||||
is_support_session: boolean;
|
||||
support_code: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface EventRecord {
|
||||
id: number;
|
||||
session_id: string;
|
||||
event_type: string;
|
||||
timestamp: string;
|
||||
viewer_id: string | null;
|
||||
viewer_name: string | null;
|
||||
details: unknown | null;
|
||||
ip_address: string | null;
|
||||
}
|
||||
|
||||
export interface MachineHistory {
|
||||
machine: Machine;
|
||||
sessions: SessionRecord[];
|
||||
events: EventRecord[];
|
||||
exported_at: string;
|
||||
}
|
||||
|
||||
export interface DeleteMachineParams {
|
||||
/** Send an uninstall command to the agent if it is online. */
|
||||
uninstall?: boolean;
|
||||
/** Include full history in the delete response before removal. */
|
||||
export?: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteMachineResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
uninstall_sent: boolean;
|
||||
history: MachineHistory | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-agent keys (admin plane)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface KeyMetadata {
|
||||
id: string;
|
||||
machine_id: string;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
}
|
||||
|
||||
/** Returned exactly once when a key is minted. `key` is plaintext `cak_...`. */
|
||||
export interface CreatedKey {
|
||||
id: string;
|
||||
machine_id: string;
|
||||
key: string;
|
||||
created_at: string;
|
||||
}
|
||||
Reference in New Issue
Block a user