Phase1
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 21s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 11:31:54 +02:00
parent 4fe25e6ec3
commit 58961000eb
123 changed files with 4231 additions and 136 deletions

View File

@@ -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",
});
}

View File

@@ -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>>;