first commit

This commit is contained in:
2026-04-01 15:17:46 +02:00
commit d50ab5f7bf
19 changed files with 2554 additions and 0 deletions

62
react/AuthGuard.tsx Normal file
View File

@@ -0,0 +1,62 @@
import { useEffect, useState } from "react";
import { Center, Spinner } from "@chakra-ui/react";
import { Navigate } from "react-router-dom";
type AuthGuardProps = {
children: React.ReactNode;
fetchCurrentUser: () => Promise<unknown>;
redirectTo?: string;
loadingFallback?: React.ReactNode;
authenticatedWrapper?: (children: React.ReactNode) => React.ReactNode;
};
export function AuthGuard({
children,
fetchCurrentUser,
redirectTo = "/login",
loadingFallback,
authenticatedWrapper
}: AuthGuardProps) {
const [state, setState] = useState<{ loading: boolean; authenticated: boolean }>({
loading: true,
authenticated: false
});
useEffect(() => {
let cancelled = false;
fetchCurrentUser()
.then(() => {
if (!cancelled) {
setState({ loading: false, authenticated: true });
}
})
.catch(() => {
if (!cancelled) {
setState({ loading: false, authenticated: false });
}
});
return () => {
cancelled = true;
};
}, [fetchCurrentUser]);
if (state.loading) {
return (
<>
{loadingFallback ?? (
<Center h="var(--app-height)">
<Spinner size="xl" />
</Center>
)}
</>
);
}
if (!state.authenticated) {
return <Navigate to={redirectTo} replace />;
}
return <>{authenticatedWrapper ? authenticatedWrapper(children) : children}</>;
}

163
react/LoginForm.tsx Normal file
View File

@@ -0,0 +1,163 @@
import type { FormEvent, ReactNode } from "react";
import { Alert, AlertDescription, AlertIcon, Button, Center, FormControl, FormLabel, HStack, Icon, Input, Stack } from "@chakra-ui/react";
import { FcGoogle } from "react-icons/fc";
import type { AuthProviderAvailability, AuthProviderKey, AuthSubmitValues, LoginMode } from "./types";
type LoginFormTexts = {
nameLabel: string;
emailLabel: string;
passwordLabel: string;
passwordConfirmLabel: string;
submitRegisterLabel: string;
submitSignInLabel: string;
toggleToRegisterLabel: string;
toggleToSignInLabel: string;
forgotPasswordLabel: string;
googleLabel: string;
slackLabel: string;
};
type LoginFormProps = {
mode: LoginMode;
texts: LoginFormTexts;
onSubmit: (values: AuthSubmitValues) => void | Promise<void>;
onModeToggle: () => void;
loading?: boolean;
oauthLoadingProvider?: AuthProviderKey | null;
providers?: AuthProviderAvailability;
onOAuthSignIn?: (provider: AuthProviderKey) => void | Promise<void>;
errorMessage?: string | null;
successMessage?: string | null;
footer?: ReactNode;
forgotPasswordLink?: ReactNode;
emailPlaceholder?: string;
namePlaceholder?: string;
};
export function LoginForm({
mode,
texts,
onSubmit,
onModeToggle,
loading = false,
oauthLoadingProvider = null,
providers,
onOAuthSignIn,
errorMessage,
successMessage,
footer,
forgotPasswordLink,
emailPlaceholder = "you@example.com",
namePlaceholder = "Jane Doe"
}: LoginFormProps) {
const registerMode = mode === "register";
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
void onSubmit({
name: String(form.get("name") ?? ""),
email: String(form.get("email") ?? ""),
password: String(form.get("password") ?? ""),
passwordConfirm: String(form.get("passwordConfirm") ?? "")
});
}
return (
<Stack spacing={5}>
{errorMessage ? (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
) : null}
{successMessage ? (
<Alert status="success" borderRadius="md">
<AlertIcon />
<AlertDescription>{successMessage}</AlertDescription>
</Alert>
) : null}
<form onSubmit={handleSubmit}>
<Stack spacing={4}>
{registerMode ? (
<FormControl isRequired>
<FormLabel>{texts.nameLabel}</FormLabel>
<Input name="name" placeholder={namePlaceholder} />
</FormControl>
) : null}
<FormControl isRequired>
<FormLabel>{texts.emailLabel}</FormLabel>
<Input name="email" type="email" placeholder={emailPlaceholder} />
</FormControl>
<FormControl isRequired>
<FormLabel>{texts.passwordLabel}</FormLabel>
<Input name="password" type="password" minLength={8} />
</FormControl>
{!registerMode && forgotPasswordLink ? <Stack align="flex-end">{forgotPasswordLink}</Stack> : null}
{registerMode ? (
<FormControl isRequired>
<FormLabel>{texts.passwordConfirmLabel}</FormLabel>
<Input name="passwordConfirm" type="password" minLength={8} />
</FormControl>
) : null}
<Button type="submit" isLoading={loading}>
{registerMode ? texts.submitRegisterLabel : texts.submitSignInLabel}
</Button>
</Stack>
</form>
{registerMode || !onOAuthSignIn || (!providers?.google && !providers?.slack) ? null : (
<HStack>
{providers.google ? (
<Button
flex={1}
bg="gray.200"
color="gray.900"
borderRadius="full"
justifyContent="flex-start"
h="56px"
px={3}
fontSize={{ base: "md", md: "lg" }}
fontWeight="semibold"
iconSpacing={4}
leftIcon={
<Center boxSize="40px" bg="white" borderRadius="full" boxShadow="sm">
<Icon as={FcGoogle} boxSize={6} />
</Center>
}
_hover={{ bg: "gray.300" }}
_active={{ bg: "gray.300" }}
isLoading={oauthLoadingProvider === "google"}
onClick={() => void onOAuthSignIn("google")}
>
{texts.googleLabel}
</Button>
) : null}
{providers.slack ? (
<Button
flex={1}
variant="outline"
isLoading={oauthLoadingProvider === "slack"}
onClick={() => void onOAuthSignIn("slack")}
>
{texts.slackLabel}
</Button>
) : null}
</HStack>
)}
<Button variant="ghost" onClick={onModeToggle}>
{registerMode ? texts.toggleToSignInLabel : texts.toggleToRegisterLabel}
</Button>
{footer ?? null}
</Stack>
);
}

View File

@@ -0,0 +1,163 @@
import type { FormEvent, ReactNode } from "react";
import {
Alert,
AlertDescription,
AlertIcon,
Button,
FormControl,
FormLabel,
Input,
Spinner,
Stack,
Text
} from "@chakra-ui/react";
import type { PasswordResetMode, PasswordResetTokenState } from "./types";
type PasswordResetRequestTexts = {
emailLabel: string;
submitLabel: string;
requestSentMessage: string;
};
type PasswordResetRequestFormProps = {
texts: PasswordResetRequestTexts;
helperText: ReactNode;
loading?: boolean;
requestSent?: boolean;
onSubmit: (values: { email: string }) => void | Promise<void>;
emailPlaceholder?: string;
};
type PasswordResetConfirmTexts = {
loadingLabel: string;
passwordLabel: string;
passwordConfirmLabel: string;
invalidLinkLabel: string;
resetSubmitLabel: string;
createSubmitLabel: string;
resetSuccessLabel: string;
createSuccessLabel: string;
};
type PasswordResetConfirmFormProps = {
texts: PasswordResetConfirmTexts;
tokenState: PasswordResetTokenState;
loading?: boolean;
completedMode?: PasswordResetMode | null;
onSubmit: (values: { password: string; passwordConfirm: string }) => void | Promise<void>;
};
export function PasswordResetRequestForm({
texts,
helperText,
loading = false,
requestSent = false,
onSubmit,
emailPlaceholder = "you@example.com"
}: PasswordResetRequestFormProps) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
void onSubmit({
email: String(form.get("email") ?? "")
});
}
return (
<Stack spacing={5}>
{requestSent ? (
<Alert status="success" borderRadius="md">
<AlertIcon />
<AlertDescription>{texts.requestSentMessage}</AlertDescription>
</Alert>
) : null}
<form onSubmit={handleSubmit}>
<Stack spacing={4}>
<FormControl isRequired>
<FormLabel>{texts.emailLabel}</FormLabel>
<Input name="email" type="email" placeholder={emailPlaceholder} />
</FormControl>
<Text fontSize="sm" color="gray.600">
{helperText}
</Text>
<Button type="submit" isLoading={loading}>
{texts.submitLabel}
</Button>
</Stack>
</form>
</Stack>
);
}
export function PasswordResetConfirmForm({
texts,
tokenState,
loading = false,
completedMode = null,
onSubmit
}: PasswordResetConfirmFormProps) {
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
void onSubmit({
password: String(form.get("password") ?? ""),
passwordConfirm: String(form.get("passwordConfirm") ?? "")
});
}
if (tokenState.status === "loading") {
return (
<Stack align="center" py={6} spacing={3}>
<Spinner />
<Text color="gray.600">{texts.loadingLabel}</Text>
</Stack>
);
}
if (tokenState.status === "invalid") {
return (
<Alert status="error" borderRadius="md">
<AlertIcon />
<AlertDescription>{tokenState.error || texts.invalidLinkLabel}</AlertDescription>
</Alert>
);
}
if (completedMode !== null) {
return (
<Alert status="success" borderRadius="md">
<AlertIcon />
<AlertDescription>{completedMode === "create" ? texts.createSuccessLabel : texts.resetSuccessLabel}</AlertDescription>
</Alert>
);
}
return (
<Stack spacing={4}>
<Text fontSize="sm" color="gray.600">
{tokenState.email}
</Text>
<form onSubmit={handleSubmit}>
<Stack spacing={4}>
<FormControl isRequired>
<FormLabel>{texts.passwordLabel}</FormLabel>
<Input name="password" type="password" minLength={8} />
</FormControl>
<FormControl isRequired>
<FormLabel>{texts.passwordConfirmLabel}</FormLabel>
<Input name="passwordConfirm" type="password" minLength={8} />
</FormControl>
<Button type="submit" isLoading={loading}>
{tokenState.mode === "create" ? texts.createSubmitLabel : texts.resetSubmitLabel}
</Button>
</Stack>
</form>
</Stack>
);
}

177
react/client.ts Normal file
View File

@@ -0,0 +1,177 @@
import type { AuthProviderAvailability, PasswordResetMode } from "./types";
type CreateAuthClientOptions = {
apiUrl: (path: string) => string;
authUrl?: (path: string) => string;
fetchImpl?: typeof fetch;
credentials?: RequestCredentials;
defaultOAuthCallbackUrl?: string | (() => string);
};
type LoginInput = {
email: string;
password: string;
};
type RegisterInput = LoginInput & {
name: string;
};
type PasswordResetValidationPayload = {
email?: string;
mode?: PasswordResetMode;
error?: string;
};
type JsonErrorPayload = {
error?: string;
};
async function readJsonError(response: Response, fallback: string): Promise<Error> {
const payload = (await response.json().catch(() => null)) as JsonErrorPayload | null;
return new Error(payload?.error ?? fallback);
}
export function createAuthClient(options: CreateAuthClientOptions) {
const fetchImpl = options.fetchImpl ?? fetch;
const authUrl = options.authUrl ?? options.apiUrl;
const credentials = options.credentials ?? "include";
function resolveDefaultOAuthCallbackUrl(): string {
const configured = options.defaultOAuthCallbackUrl;
if (typeof configured === "function") {
return configured();
}
if (typeof configured === "string" && configured.trim().length > 0) {
return configured;
}
return `${window.location.origin}/chat`;
}
async function request(path: string, init?: RequestInit): Promise<Response> {
return fetchImpl(options.apiUrl(path), {
...init,
credentials,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {})
}
});
}
return {
async getProviders(): Promise<AuthProviderAvailability> {
const response = await request("/api/auth/providers");
if (!response.ok) {
throw await readJsonError(response, "providers_unavailable");
}
return (await response.json()) as AuthProviderAvailability;
},
async getCurrentUser<TUser>(): Promise<TUser> {
const response = await request("/api/me");
if (!response.ok) {
throw await readJsonError(response, "Unauthorized");
}
const payload = (await response.json()) as { user: TUser };
return payload.user;
},
async register(input: RegisterInput): Promise<void> {
const response = await request("/api/auth/register", {
method: "POST",
body: JSON.stringify(input)
});
if (!response.ok) {
throw await readJsonError(response, "Registration failed");
}
},
async login(input: LoginInput): Promise<void> {
const response = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify(input)
});
if (!response.ok) {
throw await readJsonError(response, "Sign in failed");
}
},
async requestPasswordReset(email: string): Promise<void> {
const response = await request("/api/auth/password-reset/request", {
method: "POST",
body: JSON.stringify({ email })
});
if (!response.ok) {
throw await readJsonError(response, "Password reset request failed");
}
},
async validatePasswordResetToken(token: string): Promise<{ email: string; mode: PasswordResetMode }> {
const response = await request(`/api/auth/password-reset/validate?token=${encodeURIComponent(token)}`, {
headers: {}
});
const payload = (await response.json().catch(() => null)) as PasswordResetValidationPayload | null;
if (!response.ok || !payload?.email || (payload.mode !== "reset" && payload.mode !== "create")) {
throw new Error(payload?.error ?? "Invalid reset link");
}
return {
email: payload.email,
mode: payload.mode
};
},
async confirmPasswordReset(input: { token: string; password: string }): Promise<void> {
const response = await request("/api/auth/password-reset/confirm", {
method: "POST",
body: JSON.stringify(input)
});
if (!response.ok) {
throw await readJsonError(response, "Invalid reset link");
}
},
async logout(): Promise<void> {
const response = await request("/api/auth/logout", {
method: "POST"
});
if (!response.ok) {
throw await readJsonError(response, "Logout failed");
}
},
async startOAuthSignIn(provider: string, callbackUrl = resolveDefaultOAuthCallbackUrl()): Promise<void> {
const response = await fetchImpl(authUrl("/auth/csrf"), {
credentials
});
if (!response.ok) {
throw await readJsonError(response, "Sign in failed");
}
const payload = (await response.json()) as { csrfToken?: string };
if (!payload.csrfToken) {
throw new Error("Sign in failed");
}
const form = document.createElement("form");
form.method = "POST";
form.action = authUrl(`/auth/signin/${provider}`);
form.style.display = "none";
const csrfInput = document.createElement("input");
csrfInput.type = "hidden";
csrfInput.name = "csrfToken";
csrfInput.value = payload.csrfToken;
form.appendChild(csrfInput);
const callbackInput = document.createElement("input");
callbackInput.type = "hidden";
callbackInput.name = "callbackUrl";
callbackInput.value = callbackUrl;
form.appendChild(callbackInput);
document.body.appendChild(form);
form.submit();
}
};
}

12
react/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export { AuthGuard } from "./AuthGuard";
export { createAuthClient } from "./client";
export { LoginForm } from "./LoginForm";
export { PasswordResetConfirmForm, PasswordResetRequestForm } from "./PasswordResetForms";
export type {
AuthProviderAvailability,
AuthProviderKey,
AuthSubmitValues,
LoginMode,
PasswordResetMode,
PasswordResetTokenState
} from "./types";

19
react/types.ts Normal file
View File

@@ -0,0 +1,19 @@
export type AuthProviderKey = "google" | "slack" | (string & {});
export type AuthProviderAvailability = Partial<Record<AuthProviderKey, boolean>>;
export type LoginMode = "signIn" | "register";
export type AuthSubmitValues = {
name: string;
email: string;
password: string;
passwordConfirm: string;
};
export type PasswordResetMode = "reset" | "create";
export type PasswordResetTokenState =
| { status: "loading" }
| { status: "invalid"; error: string }
| { status: "valid"; email: string; mode: PasswordResetMode };