320 lines
9.6 KiB
TypeScript
320 lines
9.6 KiB
TypeScript
// Real in-browser API client for demo.flow-master.ai.
|
|
// - dev-login → bearer (cached in sessionStorage, refresh on 401)
|
|
// - typed wrappers for the endpoints Mission Control needs
|
|
// - all fetches honor an AbortSignal so unmounts cancel cleanly
|
|
// - never throws into render: returns { ok, error } shapes
|
|
|
|
export interface ApiConfig {
|
|
baseUrl: string;
|
|
email: string;
|
|
}
|
|
|
|
// In dev, route via vite proxy (same-origin) to dodge CORS; in production
|
|
// the build is deployed at the same origin as the backend, so an empty
|
|
// baseUrl is correct.
|
|
const DEFAULT_CONFIG: ApiConfig = {
|
|
baseUrl: import.meta.env.VITE_FM_BASE ?? "",
|
|
email: import.meta.env.VITE_FM_EMAIL || "dev@flow-master.ai",
|
|
};
|
|
|
|
const TOKEN_KEY = "fm.mc.token.v1";
|
|
|
|
let inflightLogin: Promise<string> | null = null;
|
|
|
|
async function login(cfg: ApiConfig, signal?: AbortSignal): Promise<string> {
|
|
if (inflightLogin) return inflightLogin;
|
|
inflightLogin = (async () => {
|
|
const r = await fetch(`${cfg.baseUrl}/api/v1/auth/dev-login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email: cfg.email }),
|
|
signal,
|
|
});
|
|
if (!r.ok) throw new Error(`dev-login ${r.status}`);
|
|
const body = (await r.json()) as { access_token: string };
|
|
sessionStorage.setItem(TOKEN_KEY, body.access_token);
|
|
return body.access_token;
|
|
})();
|
|
try {
|
|
return await inflightLogin;
|
|
} finally {
|
|
inflightLogin = null;
|
|
}
|
|
}
|
|
|
|
export interface WorkItem {
|
|
transaction_id: string;
|
|
short_id?: string;
|
|
status: string;
|
|
hub?: string;
|
|
age_days?: number;
|
|
active_step_display_name?: string;
|
|
current_node?: string;
|
|
next_action?: string;
|
|
definition_key: string;
|
|
business_subject?: string;
|
|
created_at?: string;
|
|
}
|
|
|
|
export interface ProcessGraph {
|
|
process_definition: {
|
|
_key: string;
|
|
name: string;
|
|
display_name?: string;
|
|
hub?: string;
|
|
config: { nodes: unknown[]; edges: unknown[]; org_id?: string };
|
|
};
|
|
version_definitions?: Array<{ version: number; approved_at?: string }>;
|
|
}
|
|
|
|
export interface RuntimeTransaction {
|
|
transaction_id: string;
|
|
status: string;
|
|
active_step?: { step_definition_id?: string; display_name?: string };
|
|
available_actions?: Array<{ display_label: string }>;
|
|
created_at?: string;
|
|
business_subject?: string;
|
|
}
|
|
|
|
export interface AuthMe {
|
|
user_id: string;
|
|
tenant_id: string;
|
|
email: string;
|
|
display_name?: string;
|
|
}
|
|
|
|
export interface Actor {
|
|
mode: "direct_user" | "agent" | "system";
|
|
user_id?: string;
|
|
}
|
|
|
|
export interface ActionResult {
|
|
transaction_id: string;
|
|
action_id: string;
|
|
status: string;
|
|
previous_step?: { display_name?: string; step_run_id?: string };
|
|
next_step?: { display_name?: string; step_definition_id?: string };
|
|
active_step?: { display_name?: string; step_definition_id?: string };
|
|
}
|
|
|
|
export type ApiCallObserver = (call: ApiCall) => void;
|
|
export interface ApiCall {
|
|
id: number;
|
|
method: string;
|
|
path: string;
|
|
status: number;
|
|
durationMs: number;
|
|
reqBody?: unknown;
|
|
resBody?: unknown;
|
|
error?: string;
|
|
at: number;
|
|
}
|
|
|
|
let callSeq = 0;
|
|
const observers = new Set<ApiCallObserver>();
|
|
const emit = (c: ApiCall) => { for (const o of observers) try { o(c); } catch { /* swallow */ } };
|
|
|
|
async function instrumentedFetch(
|
|
method: string,
|
|
url: string,
|
|
init: RequestInit,
|
|
reqBody?: unknown,
|
|
): Promise<Response> {
|
|
const id = ++callSeq;
|
|
const at = Date.now();
|
|
try {
|
|
const r = await fetch(url, init);
|
|
const cloned = r.clone();
|
|
let resBody: unknown;
|
|
try {
|
|
const text = await cloned.text();
|
|
try { resBody = JSON.parse(text); } catch { resBody = text.slice(0, 500); }
|
|
} catch { /* swallow */ }
|
|
emit({ id, method, path: url, status: r.status, durationMs: Date.now() - at, reqBody, resBody, at });
|
|
return r;
|
|
} catch (e) {
|
|
emit({ id, method, path: url, status: 0, durationMs: Date.now() - at, reqBody, error: (e as Error).message, at });
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function authedRequest<T>(
|
|
cfg: ApiConfig,
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
signal?: AbortSignal,
|
|
): Promise<T> {
|
|
let token = sessionStorage.getItem(TOKEN_KEY);
|
|
if (!token) token = await login(cfg, signal);
|
|
const init: RequestInit = {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
Accept: "application/json",
|
|
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
signal,
|
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
};
|
|
let r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body);
|
|
if (r.status === 401) {
|
|
sessionStorage.removeItem(TOKEN_KEY);
|
|
token = await login(cfg, signal);
|
|
init.headers = { ...init.headers, Authorization: `Bearer ${token}` };
|
|
r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body);
|
|
}
|
|
if (!r.ok) {
|
|
const text = await r.text().catch(() => "");
|
|
throw new Error(`${path} → ${r.status} ${text.slice(0, 200)}`);
|
|
}
|
|
return (await r.json()) as T;
|
|
}
|
|
|
|
export const api = {
|
|
config: DEFAULT_CONFIG,
|
|
|
|
/** Set the bearer token explicitly (e.g. from SSO or login form) */
|
|
setBearer(token: string) {
|
|
sessionStorage.setItem(TOKEN_KEY, token);
|
|
// Also set a non-httpOnly cookie for server-side middleware
|
|
document.cookie = `access_token=${token}; path=/; max-age=86400; SameSite=Lax`;
|
|
},
|
|
|
|
/** Subscribe to all API calls. Returns unsubscribe. */
|
|
onCall(fn: ApiCallObserver): () => void {
|
|
observers.add(fn);
|
|
return () => observers.delete(fn);
|
|
},
|
|
|
|
async me(signal?: AbortSignal): Promise<AuthMe> {
|
|
return authedRequest<AuthMe>(this.config, "GET", "/api/v1/auth/me", undefined, signal);
|
|
},
|
|
|
|
async signIn(email: string, password?: string, signal?: AbortSignal): Promise<{ access_token: string; refresh_token?: string }> {
|
|
const payload = password ? { email, password } : { email };
|
|
const path = password ? "/api/v1/auth/login" : "/api/v1/auth/dev-login";
|
|
const init: RequestInit = {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
signal,
|
|
};
|
|
const r = await instrumentedFetch("POST", `${this.config.baseUrl}${path}`, init, payload);
|
|
if (!r.ok) {
|
|
const text = await r.text().catch(() => "");
|
|
throw new Error(`Login failed: ${r.status} ${text.slice(0, 100)}`);
|
|
}
|
|
const data = await r.json() as { access_token: string; refresh_token?: string };
|
|
this.setBearer(data.access_token);
|
|
return data;
|
|
},
|
|
|
|
async devLoginConfig(signal?: AbortSignal): Promise<{ enabled: boolean }> {
|
|
try {
|
|
const init: RequestInit = { method: "GET", headers: { "Accept": "application/json" }, signal };
|
|
const r = await instrumentedFetch("GET", `${this.config.baseUrl}/internal/dev-login-config`, init);
|
|
if (r.ok) {
|
|
return await r.json() as { enabled: boolean };
|
|
}
|
|
} catch {
|
|
// swallow
|
|
}
|
|
return { enabled: false };
|
|
},
|
|
|
|
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
|
|
const body = await authedRequest<{ items?: WorkItem[] }>(this.config, "GET", "/api/ea2/work-items?view=all", undefined, signal);
|
|
return body.items ?? [];
|
|
},
|
|
|
|
async graph(defKey: string, signal?: AbortSignal): Promise<ProcessGraph | null> {
|
|
try {
|
|
return await authedRequest<ProcessGraph>(this.config, "GET", `/api/ea2/process-definitions/${defKey}/graph`, undefined, signal);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async transaction(txId: string, signal?: AbortSignal): Promise<RuntimeTransaction | null> {
|
|
try {
|
|
return await authedRequest<RuntimeTransaction>(this.config, "GET", `/api/runtime/transactions/${txId}`, undefined, signal);
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
/** Start a fresh runtime instance of a process definition. */
|
|
async startTransaction(
|
|
process_definition_id: string,
|
|
business_subject?: string,
|
|
signal?: AbortSignal,
|
|
): Promise<RuntimeTransaction> {
|
|
return authedRequest<RuntimeTransaction>(
|
|
this.config,
|
|
"POST",
|
|
"/api/runtime/transactions",
|
|
{ process_definition_id, business_subject: business_subject ?? null },
|
|
signal,
|
|
);
|
|
},
|
|
|
|
/** Execute an action against the active step of a transaction. */
|
|
async executeAction(
|
|
txId: string,
|
|
actionId: string,
|
|
actor: Actor,
|
|
values: Record<string, unknown> = {},
|
|
signal?: AbortSignal,
|
|
): Promise<ActionResult> {
|
|
return authedRequest<ActionResult>(
|
|
this.config,
|
|
"POST",
|
|
`/api/runtime/transactions/${txId}/actions/${actionId}`,
|
|
{ actor, values },
|
|
signal,
|
|
);
|
|
},
|
|
|
|
/** Create a new published process definition (writes to /api/ea2/flow). */
|
|
async createProcess(
|
|
payload: {
|
|
name: string;
|
|
display_name: string;
|
|
label?: string;
|
|
description?: string;
|
|
hub?: string;
|
|
config: { nodes: unknown[]; edges: unknown[]; org_id: string; executable?: boolean };
|
|
},
|
|
signal?: AbortSignal,
|
|
): Promise<{ _key: string; name: string }> {
|
|
return authedRequest<{ _key: string; name: string }>(
|
|
this.config,
|
|
"POST",
|
|
"/api/ea2/flow",
|
|
{
|
|
kind: "definition",
|
|
status: "published",
|
|
source_context: "mission-control-studio",
|
|
version: 1,
|
|
...payload,
|
|
},
|
|
signal,
|
|
);
|
|
},
|
|
|
|
/** Probe whether the backend is reachable. Never throws. */
|
|
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string; user_id?: string }> {
|
|
try {
|
|
const me = await this.me(signal);
|
|
return { ok: true, user: me.email, user_id: me.user_id };
|
|
} catch (e) {
|
|
return { ok: false, reason: (e as Error).message };
|
|
}
|
|
},
|
|
|
|
clearToken() {
|
|
sessionStorage.removeItem(TOKEN_KEY);
|
|
},
|
|
};
|