Files
siren/ui/src/api.ts
2026-04-04 12:22:17 -04:00

233 lines
7.1 KiB
TypeScript

import type {
GridMap,
ListedMap,
MapAccessRequest,
MapPermission,
MapRole,
MapState,
PublicAccess,
UserInfo,
} from "./types";
const GRID_BASE = "/api/grid";
const AUTH_BASE = "/api/auth";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
credentials: "include",
headers: {
...(init?.headers as Record<string, string>),
},
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`);
}
// Read the bdoy if it exists
const text = await res.text();
if (!text) return undefined as T;
// Parse JSON if it exists
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return JSON.parse(text) as T;
} else if (contentType.includes("text/plain")) {
return text as unknown as T;
}
throw new Error(`Expected JSON or text but got: ${contentType}`);
}
export const api = {
/** List maps where the authenticated user has a direct role or has favorited. */
listMaps: (): Promise<ListedMap[]> =>
request<ListedMap[]>(`${GRID_BASE}/maps`),
/** Create a new map (authenticated). */
createMap: (
name: string,
public_access: PublicAccess = "private",
): Promise<GridMap> =>
request<GridMap>(`${GRID_BASE}/maps`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, public_access }),
}),
/** Get full map state (cells, tokens, colors). */
getMap: (id: string): Promise<MapState> =>
request<MapState>(`${GRID_BASE}/maps/${id}`),
/** Update map name and/or public_access (owner only). */
updateMap: (
id: string,
payload: { name?: string; public_access?: PublicAccess },
): Promise<GridMap> =>
request<GridMap>(`${GRID_BASE}/maps/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
/** Delete a map (owner only). */
deleteMap: (id: string): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${id}`, { method: "DELETE" }),
// ---- Permissions ----
/** List all permissions for a map including usernames (owner only). */
listPermissions: (mapId: string): Promise<MapPermission[]> =>
request<MapPermission[]>(`${GRID_BASE}/maps/${mapId}/permissions`),
/**
* Add or update a user's role by username.
* Pass `role: null` to remove the user's permission entirely.
*/
updatePermission: (
mapId: string,
username: string,
role: MapRole | null,
): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${mapId}/permissions`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, role }),
}),
/** Favorite a map (adds it to the user's map list). */
favoriteMap: (id: string): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "POST" }),
/** Un-favorite a map. */
unfavoriteMap: (id: string): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${id}/favorite`, { method: "DELETE" }),
/** Request viewer or editor access to a map. */
requestAccess: (mapId: string, role: "editor" | "viewer"): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role }),
}),
/** List pending access requests for a map (owner only). */
listAccessRequests: (mapId: string): Promise<MapAccessRequest[]> =>
request<MapAccessRequest[]>(`${GRID_BASE}/maps/${mapId}/access-requests`),
/** Approve or deny a pending access request (owner only). */
resolveAccessRequest: (
mapId: string,
requestId: string,
action: "approve" | "deny",
): Promise<void> =>
request<void>(`${GRID_BASE}/maps/${mapId}/access-requests/${requestId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
}),
};
export const auth = {
/** Fetch the currently authenticated user's info. Returns null if not logged in. */
async me(): Promise<UserInfo | null> {
try {
return await request<UserInfo>(`${AUTH_BASE}/me`);
} catch {
return null;
}
},
/** Register a new local account. */
async register(username: string, password: string): Promise<void> {
await request<void>(`${AUTH_BASE}/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
},
/** Login with username and password. */
async loginLocal(username: string, password: string): Promise<void> {
await request<void>(`${AUTH_BASE}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
},
/** Start Discord OAuth login flow (anonymous). */
async loginDiscord(redirectUri?: string): Promise<void> {
const target = encodeURIComponent(redirectUri ?? window.location.href);
const response = await request<string>(
`${AUTH_BASE}/discord/authorize?redirect_uri=${target}`,
);
window.location.href = JSON.parse(response);
},
/** Start Discord OAuth connect flow (authenticated users only). */
async connectDiscord(redirectUri?: string): Promise<void> {
const target = encodeURIComponent(
redirectUri ?? window.location.origin + "/account",
);
const response = await request<string>(
`${AUTH_BASE}/discord/connect?redirect_uri=${target}`,
);
window.location.href = JSON.parse(response);
},
/** Clear the session cookie server-side and reload. */
async logout(): Promise<void> {
try {
await request<void>(`${AUTH_BASE}/logout`, { method: "POST" });
} catch {
// Ignore errors
}
window.location.href = "/map";
},
/**
* Update the user's first and/or last name.
* Pass an empty string to clear a field; pass undefined to leave it unchanged.
*/
async updateProfile(
firstName?: string,
lastName?: string,
): Promise<UserInfo> {
return request<UserInfo>(`${AUTH_BASE}/profile`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ first_name: firstName, last_name: lastName }),
});
},
/**
* Change or set the account password.
* `currentPassword` is required when the account already has a password,
* and can be omitted (null/undefined) for OAuth-only accounts setting a
* password for the first time.
*/
async changePassword(
currentPassword: string | null,
newPassword: string,
): Promise<void> {
await request<void>(`${AUTH_BASE}/change-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
current_password: currentPassword ?? undefined,
new_password: newPassword,
}),
});
},
/** Disconnect an OAuth provider (requires a password to be set first). */
async disconnectProvider(provider: string): Promise<void> {
await request<void>(`${AUTH_BASE}/connections/${provider}`, {
method: "DELETE",
});
},
};