presentercoach / apps /web /src /lib /api.ts
nathycodes's picture
Deploy Tech Presenter Coach
ec17af3
Raw History Blame Contribute Delete
8.75 kB
export const API_URL = (process.env.NEXT_PUBLIC_API_URL || "/api/backend").replace(/\/+$/, "");
const SESSION_TOKEN_KEY = "sm_presenter_access_token";
const SESSION_TOKEN_EXP_KEY = "sm_presenter_access_exp_ms";
const REFRESH_TOKEN_KEY = "sm_presenter_refresh_token";
export type AuthTokenResponse = {
access_token: string;
refresh_token?: string | null;
expires_in?: number | null;
user?: { id: number; email: string; is_admin?: boolean };
};
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
let accessToken: string | null = null;
let accessTokenExpMs: number | null = null;
let refreshTokenMem: string | null = null;
let refreshPromise: Promise<string | null> | null = null;
const readStorageItem = (key: string): string | null => {
if (typeof window === "undefined") return null;
try {
const localVal = window.localStorage.getItem(key);
if (localVal) return localVal;
} catch {
// Ignore localStorage sandbox restrictions
}
try {
return window.sessionStorage.getItem(key);
} catch {
return null;
}
};
const writeStorageItem = (key: string, value: string | null) => {
if (typeof window === "undefined") return;
for (const store of ["localStorage", "sessionStorage"] as const) {
try {
if (value) {
window[store].setItem(key, value);
} else {
window[store].removeItem(key);
}
} catch {
// Ignore storage quota or sandbox restrictions
}
}
};
const decodeBase64Url = (value: string) => {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
if (typeof atob === "function") {
return atob(padded);
}
return Buffer.from(padded, "base64").toString("utf8");
};
const parseTokenExpMs = (token: string): number | null => {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const payload = JSON.parse(decodeBase64Url(parts[1])) as { exp?: number };
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
} catch {
return null;
}
};
export const getToken = () => {
if (!accessToken) {
const stored = readStorageItem(SESSION_TOKEN_KEY);
if (stored) {
accessToken = stored;
}
}
return accessToken;
};
const getTokenExpMs = (token: string): number | null => {
if (accessTokenExpMs !== null) {
return accessTokenExpMs;
}
const storedExp = readStorageItem(SESSION_TOKEN_EXP_KEY);
if (storedExp) {
const parsed = Number(storedExp);
if (Number.isFinite(parsed) && parsed > 0) {
accessTokenExpMs = parsed;
return parsed;
}
}
return parseTokenExpMs(token);
};
export const getRefreshToken = () => {
if (!refreshTokenMem) {
const stored = readStorageItem(REFRESH_TOKEN_KEY);
if (stored) {
refreshTokenMem = stored;
}
}
return refreshTokenMem;
};
export const hasValidToken = () => {
const token = getToken();
if (!token) return false;
const expMs = getTokenExpMs(token);
if (!expMs) return false;
return expMs - 5_000 > Date.now();
};
export const setToken = (
token: string,
expiresInSeconds?: number | null,
refreshToken?: string | null
) => {
accessToken = token;
writeStorageItem(SESSION_TOKEN_KEY, token);
if (typeof expiresInSeconds === "number" && expiresInSeconds > 0) {
accessTokenExpMs = Date.now() + expiresInSeconds * 1000;
writeStorageItem(SESSION_TOKEN_EXP_KEY, String(accessTokenExpMs));
} else {
accessTokenExpMs = null;
writeStorageItem(SESSION_TOKEN_EXP_KEY, null);
}
if (refreshToken) {
refreshTokenMem = refreshToken;
writeStorageItem(REFRESH_TOKEN_KEY, refreshToken);
}
};
export const clearToken = () => {
accessToken = null;
accessTokenExpMs = null;
refreshTokenMem = null;
writeStorageItem(SESSION_TOKEN_KEY, null);
writeStorageItem(SESSION_TOKEN_EXP_KEY, null);
writeStorageItem(REFRESH_TOKEN_KEY, null);
};
const parseErrorMessage = async (response: Response) => {
const contentType = response.headers.get("content-type") || "";
if (contentType.includes("application/json")) {
const payload = await response.json().catch(() => null);
if (payload && typeof payload.detail === "string") {
return payload.detail;
}
}
const message = await response.text().catch(() => "");
return message || `Request failed (${response.status})`;
};
export const refreshAccessToken = async (): Promise<string | null> => {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
try {
const headers = new Headers();
const storedRefresh = getRefreshToken();
let body: string | undefined;
if (storedRefresh) {
headers.set("X-Refresh-Token", storedRefresh);
headers.set("Content-Type", "application/json");
body = JSON.stringify({ refresh_token: storedRefresh });
}
const response = await fetch(`${API_URL}/auth/refresh`, {
method: "POST",
headers,
body,
credentials: "include",
});
if (!response.ok) {
clearToken();
return null;
}
const payload = (await response.json()) as AuthTokenResponse;
if (!payload.access_token) {
clearToken();
return null;
}
setToken(payload.access_token, payload.expires_in, payload.refresh_token);
return payload.access_token;
} catch {
clearToken();
return null;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
};
export const ensureAccessToken = async () => {
const current = getToken();
if (current && hasValidToken()) {
return current;
}
return refreshAccessToken();
};
type FetchMode = "json" | "blob" | "response";
const performFetch = async (
path: string,
options: RequestInit = {},
allowRefresh = true
): Promise<Response> => {
const headers = new Headers(options.headers || {});
const skipAuth =
path.startsWith("/auth/login") ||
path.startsWith("/auth/signup") ||
path.startsWith("/auth/refresh") ||
path.startsWith("/setup");
const token = skipAuth ? null : getToken() || (allowRefresh ? await refreshAccessToken() : null);
let targetUrl = `${API_URL}${path}`;
if (token) {
headers.set("Authorization", `Bearer ${token}`);
headers.set("X-Presenter-Token", token);
const separator = targetUrl.includes("?") ? "&" : "?";
targetUrl = `${targetUrl}${separator}_pt=${encodeURIComponent(token)}`;
}
if (!headers.has("Content-Type") && !(options.body instanceof FormData) && options.body !== undefined) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(targetUrl, {
...options,
headers,
credentials: "include",
});
if (response.status === 401 && allowRefresh && !skipAuth) {
const refreshed = await refreshAccessToken();
if (refreshed) {
return performFetch(path, options, false);
}
}
if (!response.ok) {
throw new ApiError(response.status, await parseErrorMessage(response));
}
return response;
};
export async function apiFetch<T>(path: string, options: RequestInit = {}, mode: FetchMode = "json"): Promise<T> {
const response = await performFetch(path, options);
if (mode === "response") {
return response as T;
}
if (mode === "blob") {
return (await response.blob()) as T;
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export const authApi = {
setupStatus: () => apiFetch<{ setup_required: boolean }>("/setup/status"),
setup: (email: string, password: string) =>
apiFetch<AuthTokenResponse>("/setup", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
login: (email: string, password: string) =>
apiFetch<AuthTokenResponse>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
signup: (email: string, password: string) =>
apiFetch<AuthTokenResponse>("/auth/signup", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
logout: () => {
const storedRefresh = getRefreshToken();
const headers: Record<string, string> = {};
let body: string | undefined;
if (storedRefresh) {
headers["X-Refresh-Token"] = storedRefresh;
body = JSON.stringify({ refresh_token: storedRefresh });
}
return apiFetch<{ status: string }>("/auth/logout", {
method: "POST",
headers,
body,
});
},
me: () => apiFetch<{ id: number; email: string; is_admin: boolean }>("/auth/me"),
};