169 lines
4.5 KiB
TypeScript
169 lines
4.5 KiB
TypeScript
// Central API client for the identity platform web app.
|
|
// The auth lifecycle (login/register/refresh/logout) is handled by the Next.js
|
|
// Route Handlers under app/api/auth/*, which set HttpOnly cookies so the
|
|
// server-side middleware can guard routes. Data calls attach a Bearer token
|
|
// from localStorage (mirroring Hamsoo's apiclient token-storage pattern).
|
|
|
|
export const ACCESS_TOKEN_KEY = "access_token";
|
|
export const REFRESH_TOKEN_KEY = "refresh_token";
|
|
export const SESSION_ID_KEY = "session_id";
|
|
|
|
// Same-origin proxy base for auth lifecycle calls (sets HttpOnly cookies).
|
|
const AUTH_PROXY = "/api/auth";
|
|
|
|
export function getAccessToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
|
}
|
|
|
|
export function getRefreshToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
}
|
|
|
|
export function setTokens(
|
|
access: string,
|
|
refresh: string,
|
|
sessionId?: string,
|
|
): void {
|
|
if (typeof window === "undefined") return;
|
|
localStorage.setItem(ACCESS_TOKEN_KEY, access);
|
|
localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
|
|
if (sessionId) localStorage.setItem(SESSION_ID_KEY, sessionId);
|
|
}
|
|
|
|
export function clearTokens(): void {
|
|
if (typeof window === "undefined") return;
|
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
localStorage.removeItem(SESSION_ID_KEY);
|
|
}
|
|
|
|
function apiBase(): string {
|
|
return process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/v1";
|
|
}
|
|
|
|
export interface ApiResult {
|
|
ok: boolean;
|
|
status: number;
|
|
data: any;
|
|
}
|
|
|
|
export async function authLogin(
|
|
email: string,
|
|
password: string,
|
|
mfaCode?: string,
|
|
): Promise<ApiResult> {
|
|
const res = await fetch(`${AUTH_PROXY}/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
email,
|
|
password,
|
|
mfa_code: mfaCode || "",
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
return { ok: res.ok, status: res.status, data };
|
|
}
|
|
|
|
export async function authRegister(payload: {
|
|
email: string;
|
|
password: string;
|
|
password_confirm: string;
|
|
full_name?: string;
|
|
username?: string;
|
|
}): Promise<ApiResult> {
|
|
const res = await fetch(`${AUTH_PROXY}/register`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
return { ok: res.ok, status: res.status, data };
|
|
}
|
|
|
|
export async function authLogout(refresh?: string | null): Promise<boolean> {
|
|
try {
|
|
const res = await fetch(`${AUTH_PROXY}/logout`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
refresh: refresh || getRefreshToken() || "",
|
|
logout_type: "global",
|
|
}),
|
|
});
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function tryRefresh(): Promise<string | null> {
|
|
const refresh = getRefreshToken();
|
|
if (!refresh) {
|
|
clearTokens();
|
|
return null;
|
|
}
|
|
try {
|
|
const res = await fetch(`${AUTH_PROXY}/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh }),
|
|
});
|
|
if (!res.ok) {
|
|
clearTokens();
|
|
return null;
|
|
}
|
|
const data = await res.json();
|
|
const access = data.access;
|
|
if (!access) return null;
|
|
setTokens(access, data.refresh ?? refresh, data.session_id);
|
|
return access;
|
|
} catch {
|
|
clearTokens();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function apiFetch(
|
|
url: string,
|
|
options: RequestInit = {},
|
|
): Promise<Response> {
|
|
const fullUrl = url.startsWith("http") ? url : `${apiBase()}${url}`;
|
|
const token = getAccessToken();
|
|
|
|
const buildHeaders = (authToken?: string): Headers => {
|
|
const headers = new Headers(options.headers);
|
|
if (!headers.has("Content-Type")) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
if (authToken) headers.set("Authorization", `Bearer ${authToken}`);
|
|
return headers;
|
|
};
|
|
|
|
let res = await fetch(fullUrl, {
|
|
...options,
|
|
headers: buildHeaders(token || undefined),
|
|
});
|
|
|
|
// Automatic refresh on 401 (single retry).
|
|
if (res.status === 401) {
|
|
const newToken = await tryRefresh();
|
|
if (newToken) {
|
|
res = await fetch(fullUrl, {
|
|
...options,
|
|
headers: buildHeaders(newToken),
|
|
});
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
export async function fetchMe(): Promise<any | null> {
|
|
const res = await apiFetch("/auth/me/");
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
}
|