Phase1
This commit is contained in:
@@ -1,45 +1,211 @@
|
||||
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 AuthResponse {
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
export interface RegisterPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepared API client for Phase 1 authentication.
|
||||
* Currently validates structure only; no network request is performed.
|
||||
*/
|
||||
export async function submitLogin(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<AuthResponse> {
|
||||
const endpoint = `${getApiUrl()}/auth/login`;
|
||||
export interface LoginResponse {
|
||||
twoFactorRequired?: boolean;
|
||||
challengeId?: string;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
displayName?: string | null;
|
||||
emailVerifiedAt?: string | null;
|
||||
roles?: string[];
|
||||
twoFactorEnabled?: boolean;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
void credentials;
|
||||
void endpoint;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
return Promise.resolve({});
|
||||
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(
|
||||
credentials: AuthCredentials,
|
||||
): Promise<AuthResponse> {
|
||||
const endpoint = `${getApiUrl()}/auth/register`;
|
||||
|
||||
if (!endpoint.startsWith("http")) {
|
||||
throw new Error("Invalid API URL configuration");
|
||||
}
|
||||
|
||||
void credentials;
|
||||
void endpoint;
|
||||
|
||||
return Promise.resolve({});
|
||||
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> {
|
||||
return apiFetch<CurrentUser>("/me");
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function createRegisterSchema(messages: {
|
||||
passwordMin: string;
|
||||
confirmPasswordRequired: string;
|
||||
passwordMismatch: string;
|
||||
usernameInvalid?: string;
|
||||
}) {
|
||||
return z
|
||||
.object({
|
||||
@@ -32,6 +33,13 @@ export function createRegisterSchema(messages: {
|
||||
.string()
|
||||
.min(1, messages.emailRequired)
|
||||
.email(messages.emailInvalid),
|
||||
username: z
|
||||
.string()
|
||||
.refine((val) => val === "" || /^[a-zA-Z0-9_-]{3,32}$/.test(val), {
|
||||
message: messages.usernameInvalid ?? "Invalid username",
|
||||
})
|
||||
.default(""),
|
||||
displayName: z.string().max(64).default(""),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, messages.passwordRequired)
|
||||
@@ -44,5 +52,56 @@ export function createRegisterSchema(messages: {
|
||||
});
|
||||
}
|
||||
|
||||
export function createForgotPasswordSchema(messages: {
|
||||
emailRequired: string;
|
||||
emailInvalid: string;
|
||||
}) {
|
||||
return z.object({
|
||||
email: z
|
||||
.string()
|
||||
.min(1, messages.emailRequired)
|
||||
.email(messages.emailInvalid),
|
||||
});
|
||||
}
|
||||
|
||||
export function createResetPasswordSchema(messages: {
|
||||
passwordRequired: string;
|
||||
passwordMin: string;
|
||||
confirmPasswordRequired: string;
|
||||
passwordMismatch: string;
|
||||
}) {
|
||||
return z
|
||||
.object({
|
||||
password: z
|
||||
.string()
|
||||
.min(1, messages.passwordRequired)
|
||||
.min(8, messages.passwordMin),
|
||||
confirmPassword: z.string().min(1, messages.confirmPasswordRequired),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: messages.passwordMismatch,
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
}
|
||||
|
||||
export function createTwoFactorSchema(messages: {
|
||||
codeRequired: string;
|
||||
codeInvalid: string;
|
||||
}) {
|
||||
return z.object({
|
||||
code: z
|
||||
.string()
|
||||
.min(1, messages.codeRequired)
|
||||
.regex(/^\d{6}$/, messages.codeInvalid),
|
||||
});
|
||||
}
|
||||
|
||||
export type LoginFormValues = z.infer<ReturnType<typeof createLoginSchema>>;
|
||||
export type RegisterFormValues = z.infer<ReturnType<typeof createRegisterSchema>>;
|
||||
export type ForgotPasswordFormValues = z.infer<
|
||||
ReturnType<typeof createForgotPasswordSchema>
|
||||
>;
|
||||
export type ResetPasswordFormValues = z.infer<
|
||||
ReturnType<typeof createResetPasswordSchema>
|
||||
>;
|
||||
export type TwoFactorFormValues = z.infer<ReturnType<typeof createTwoFactorSchema>>;
|
||||
|
||||
Reference in New Issue
Block a user