Major refactor

This commit is contained in:
2026-04-03 23:04:51 -04:00
parent e7f337c735
commit 35d07e8df1
124 changed files with 4929 additions and 2429 deletions

126
ui/src/api.ts Normal file
View File

@@ -0,0 +1,126 @@
import type { GridMap, MapPermission, MapRole, MapState, TokenClaims } from './types';
const BASE = '/api/grid';
const AUTH_BASE = '/api/auth/discord';
// ---------------------------------------------------------------------------
// Token helpers
// ---------------------------------------------------------------------------
const TOKEN_KEY = 'siren_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
/** Decode the JWT payload without verifying the signature (client-side only). */
export function decodeToken(token: string): TokenClaims | null {
try {
const payload = token.split('.')[1];
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(json) as TokenClaims;
} catch {
return null;
}
}
/** Returns true if the stored token is present and not expired. */
// export function isAuthenticated(): boolean {
// const token = getToken();
// if (!token) return false;
// const claims = decodeToken(token);
// if (!claims) return false;
// return claims.exp > Date.now() / 1000;
// }
// ---------------------------------------------------------------------------
// Core fetch wrapper
// ---------------------------------------------------------------------------
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
...(init?.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(url, { ...init, headers });
if (res.status === 401) {
// Token expired or invalid — clear local storage
removeToken();
}
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
// ---------------------------------------------------------------------------
// Grid map API
// ---------------------------------------------------------------------------
export const api = {
listMaps: (): Promise<GridMap[]> =>
request<GridMap[]>(`${BASE}/maps`),
createMap: (name: string, is_public = false): Promise<GridMap> =>
request<GridMap>(`${BASE}/maps`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, is_public }),
}),
getMap: (id: string): Promise<MapState> =>
request<MapState>(`${BASE}/maps/${id}`),
deleteMap: (id: string): Promise<void> =>
request<void>(`${BASE}/maps/${id}`, { method: 'DELETE' }),
// ---- Permissions ----
listPermissions: (mapId: string): Promise<MapPermission[]> =>
request<MapPermission[]>(`${BASE}/maps/${mapId}/permissions`),
updatePermission: (mapId: string, userId: number, role: MapRole | null): Promise<void> =>
request<void>(`${BASE}/maps/${mapId}/permissions`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: userId, role }),
}),
};
// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------
export const auth = {
/** Fetches the Discord OAuth URL and redirects the browser to it.
* Passes the current page's origin + /map as the UI redirect URI so
* the backend knows where to send the browser after login completes.
*/
async login(): Promise<void> {
const redirectUri = encodeURIComponent(window.location.origin + '/map');
const url = await request<string>(`${AUTH_BASE}/authorize?redirect_uri=${redirectUri}`);
window.location.href = url;
},
logout(): void {
removeToken();
window.location.href = '/map';
},
};