Updating auth
This commit is contained in:
298
ui/src/api.ts
298
ui/src/api.ts
@@ -1,126 +1,232 @@
|
||||
import type { GridMap, MapPermission, MapRole, MapState, TokenClaims } from './types';
|
||||
import type {
|
||||
GridMap,
|
||||
ListedMap,
|
||||
MapAccessRequest,
|
||||
MapPermission,
|
||||
MapRole,
|
||||
MapState,
|
||||
PublicAccess,
|
||||
UserInfo,
|
||||
} 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
|
||||
// ---------------------------------------------------------------------------
|
||||
const GRID_BASE = "/api/grid";
|
||||
const AUTH_BASE = "/api/auth";
|
||||
|
||||
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();
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grid map API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const api = {
|
||||
listMaps: (): Promise<GridMap[]> =>
|
||||
request<GridMap[]>(`${BASE}/maps`),
|
||||
/** List maps where the authenticated user has a direct role or has favorited. */
|
||||
listMaps: (): Promise<ListedMap[]> =>
|
||||
request<ListedMap[]>(`${GRID_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 }),
|
||||
/** 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>(`${BASE}/maps/${id}`),
|
||||
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>(`${BASE}/maps/${id}`, { method: 'DELETE' }),
|
||||
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[]>(`${BASE}/maps/${mapId}/permissions`),
|
||||
request<MapPermission[]>(`${GRID_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 }),
|
||||
/**
|
||||
* 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 }),
|
||||
}),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
/** 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;
|
||||
}
|
||||
},
|
||||
|
||||
logout(): void {
|
||||
removeToken();
|
||||
window.location.href = '/map';
|
||||
/** 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",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user