first commit
This commit is contained in:
62
react/AuthGuard.tsx
Normal file
62
react/AuthGuard.tsx
Normal 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
163
react/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
163
react/PasswordResetForms.tsx
Normal file
163
react/PasswordResetForms.tsx
Normal 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
177
react/client.ts
Normal 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
12
react/index.ts
Normal 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
19
react/types.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user