Files
pesco-ncr/frontend/src/api/client.ts

136 lines
4.0 KiB
TypeScript
Raw Normal View History

import {
InteractionRequiredAuthError,
PublicClientApplication,
} from "@azure/msal-browser";
import { config } from "../config";
export const msalInstance =
config.authMode === "entra"
? new PublicClientApplication({
auth: {
clientId: config.clientId,
authority: `https://login.microsoftonline.com/${config.tenantId}`,
redirectUri: window.location.origin,
postLogoutRedirectUri: window.location.origin,
},
cache: { cacheLocation: "sessionStorage" },
})
: null;
const DEV_USER_KEY = "pesco-ncr-dev-user";
export function getDevUser(): string {
return localStorage.getItem(DEV_USER_KEY) || "admin@pescoinc.biz";
}
export function setDevUser(email: string): void {
localStorage.setItem(DEV_USER_KEY, email);
}
async function authHeaders(): Promise<Record<string, string>> {
if (config.authMode === "dev") {
return { "X-Dev-User": getDevUser() };
}
const instance = msalInstance!;
const account = instance.getActiveAccount() ?? instance.getAllAccounts()[0];
if (!account) {
await instance.loginRedirect({ scopes: [config.apiScope] });
throw new Error("Redirecting to sign in…");
}
try {
const result = await instance.acquireTokenSilent({
scopes: [config.apiScope],
account,
});
return { Authorization: `Bearer ${result.accessToken}` };
} catch (err) {
if (err instanceof InteractionRequiredAuthError) {
await instance.acquireTokenRedirect({ scopes: [config.apiScope], account });
}
throw err;
}
}
export class ApiError extends Error {
status: number;
constructor(status: number, detail: string) {
super(detail);
this.status = status;
}
}
async function parseError(resp: Response): Promise<ApiError> {
let detail = `Request failed (${resp.status})`;
try {
const body = await resp.json();
if (typeof body.detail === "string") detail = body.detail;
else if (Array.isArray(body.detail) && body.detail[0]?.msg)
detail = body.detail
.map((d: { loc?: unknown[]; msg: string }) => d.msg)
.join("; ");
} catch {
/* non-JSON body */
}
return new ApiError(resp.status, detail);
}
export async function api<T>(
path: string,
options: { method?: string; body?: unknown } = {},
): Promise<T> {
const headers: Record<string, string> = await authHeaders();
const init: RequestInit = { method: options.method ?? "GET", headers };
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(options.body);
}
const resp = await fetch(path, init);
if (!resp.ok) throw await parseError(resp);
if (resp.status === 204) return undefined as T;
return (await resp.json()) as T;
}
export async function apiUpload<T>(path: string, form: FormData): Promise<T> {
const headers = await authHeaders();
const resp = await fetch(path, { method: "POST", headers, body: form });
if (!resp.ok) throw await parseError(resp);
return (await resp.json()) as T;
}
export async function apiBlob(path: string): Promise<Blob> {
const headers = await authHeaders();
const resp = await fetch(path, { headers });
if (!resp.ok) throw await parseError(resp);
return resp.blob();
}
/** Fetch a protected file and hand it to the browser (download or new tab). */
export async function openBlob(
path: string,
filename: string,
mode: "download" | "open",
): Promise<void> {
const blob = await apiBlob(path);
const url = URL.createObjectURL(blob);
if (mode === "open") {
window.open(url, "_blank");
} else {
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
}
setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
export function buildQuery(params: Record<string, unknown>): string {
const q = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
q.set(key, String(value));
}
}
const s = q.toString();
return s ? `?${s}` : "";
}