249 lines
5.6 KiB
TypeScript
249 lines
5.6 KiB
TypeScript
import { getApiUrl } from "../env";
|
|
|
|
export interface ProblemDetails {
|
|
type: string;
|
|
title: string;
|
|
status: number;
|
|
detail?: string;
|
|
instance?: string;
|
|
errors?: { field?: string; message: string }[];
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
readonly status: number;
|
|
readonly title: string;
|
|
readonly detail?: string;
|
|
readonly errors?: { field?: string; message: string }[];
|
|
|
|
constructor(problem: ProblemDetails) {
|
|
super(problem.detail ?? problem.title);
|
|
this.name = "ApiError";
|
|
this.status = problem.status;
|
|
this.title = problem.title;
|
|
this.detail = problem.detail;
|
|
this.errors = problem.errors;
|
|
}
|
|
}
|
|
|
|
export interface AuthCredentials {
|
|
email: string;
|
|
password: string;
|
|
}
|
|
|
|
export interface RegisterPayload {
|
|
email: string;
|
|
password: string;
|
|
username?: string;
|
|
displayName?: string;
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
twoFactorRequired?: boolean;
|
|
challengeId?: string;
|
|
}
|
|
|
|
export interface CurrentUser {
|
|
id: string;
|
|
email: string;
|
|
username: string;
|
|
displayName?: string | null;
|
|
emailVerifiedAt?: string | null;
|
|
emailVerified?: boolean;
|
|
roles?: string[];
|
|
twoFactorEnabled?: boolean;
|
|
impersonation?: {
|
|
isImpersonation: boolean;
|
|
label?: string;
|
|
impersonatorLabel?: string;
|
|
};
|
|
}
|
|
|
|
export interface MeResponse {
|
|
user: CurrentUser;
|
|
impersonation?: {
|
|
isImpersonation: boolean;
|
|
impersonatorLabel?: string;
|
|
label?: string;
|
|
};
|
|
}
|
|
|
|
export interface TwoFactorSetupResponse {
|
|
secret: string;
|
|
qrCodeUrl: string;
|
|
recoveryCodes?: string[];
|
|
}
|
|
|
|
export interface UserSession {
|
|
id: string;
|
|
ipAddress?: string | null;
|
|
userAgent?: string | null;
|
|
createdAt: string;
|
|
expiresAt: string;
|
|
current?: boolean;
|
|
}
|
|
|
|
async function parseProblemResponse(response: Response): Promise<ApiError> {
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
|
|
if (contentType.includes("application/problem+json")) {
|
|
const problem = (await response.json()) as ProblemDetails;
|
|
return new ApiError({
|
|
...problem,
|
|
status: problem.status ?? response.status,
|
|
title: problem.title ?? "Request failed",
|
|
});
|
|
}
|
|
|
|
return new ApiError({
|
|
type: "about:blank",
|
|
title: "Request failed",
|
|
status: response.status,
|
|
detail: response.statusText || undefined,
|
|
});
|
|
}
|
|
|
|
export async function apiFetch<T>(
|
|
path: string,
|
|
options: RequestInit = {},
|
|
): Promise<T> {
|
|
const endpoint = `${getApiUrl()}${path}`;
|
|
|
|
if (!endpoint.startsWith("http")) {
|
|
throw new Error("Invalid API URL configuration");
|
|
}
|
|
|
|
const headers = new Headers(options.headers);
|
|
|
|
if (options.body && !headers.has("Content-Type")) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
|
|
const response = await fetch(endpoint, {
|
|
...options,
|
|
credentials: "include",
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw await parseProblemResponse(response);
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
|
|
const text = await response.text();
|
|
if (!text) {
|
|
return undefined as T;
|
|
}
|
|
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
export async function submitLogin(
|
|
credentials: AuthCredentials,
|
|
): Promise<LoginResponse> {
|
|
return apiFetch<LoginResponse>("/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify(credentials),
|
|
});
|
|
}
|
|
|
|
export async function submitRegister(
|
|
payload: RegisterPayload,
|
|
): Promise<void> {
|
|
await apiFetch<void>("/auth/register", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export async function submitLogout(): Promise<void> {
|
|
await apiFetch<void>("/auth/logout", {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export async function getCurrentUser(): Promise<CurrentUser | null> {
|
|
try {
|
|
const response = await apiFetch<MeResponse>("/me");
|
|
return {
|
|
...response.user,
|
|
impersonation: response.impersonation
|
|
? {
|
|
isImpersonation: response.impersonation.isImpersonation,
|
|
label:
|
|
response.impersonation.label ??
|
|
response.impersonation.impersonatorLabel,
|
|
}
|
|
: undefined,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function consumeSsoTicket(ticket: string): Promise<{ redirectPath: string }> {
|
|
return apiFetch<{ redirectPath: string }>("/auth/sso/consume", {
|
|
method: "POST",
|
|
body: JSON.stringify({ ticket }),
|
|
});
|
|
}
|
|
|
|
export async function verifyEmail(token: string): Promise<void> {
|
|
await apiFetch<void>("/auth/verify-email", {
|
|
method: "POST",
|
|
body: JSON.stringify({ token }),
|
|
});
|
|
}
|
|
|
|
export async function forgotPassword(email: string): Promise<void> {
|
|
await apiFetch<void>("/auth/forgot-password", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email }),
|
|
});
|
|
}
|
|
|
|
export async function resetPassword(
|
|
token: string,
|
|
newPassword: string,
|
|
): Promise<void> {
|
|
await apiFetch<void>("/auth/reset-password", {
|
|
method: "POST",
|
|
body: JSON.stringify({ token, newPassword }),
|
|
});
|
|
}
|
|
|
|
export async function setupTwoFactor(): Promise<TwoFactorSetupResponse> {
|
|
return apiFetch<TwoFactorSetupResponse>("/auth/2fa/setup", {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export async function confirmTwoFactor(code: string): Promise<void> {
|
|
await apiFetch<void>("/auth/2fa/confirm", {
|
|
method: "POST",
|
|
body: JSON.stringify({ code }),
|
|
});
|
|
}
|
|
|
|
export async function verifyTwoFactorLogin(
|
|
challengeId: string,
|
|
code: string,
|
|
): Promise<void> {
|
|
await apiFetch<void>("/auth/2fa/verify", {
|
|
method: "POST",
|
|
body: JSON.stringify({ challengeId, code }),
|
|
});
|
|
}
|
|
|
|
export async function getSessions(): Promise<UserSession[]> {
|
|
return apiFetch<UserSession[]>("/me/sessions");
|
|
}
|
|
|
|
export async function revokeSession(sessionId: string): Promise<void> {
|
|
await apiFetch<void>(`/me/sessions/${sessionId}`, {
|
|
method: "DELETE",
|
|
});
|
|
}
|