Ajoute le flux d'invitation avec activation de compte

This commit is contained in:
2026-07-19 19:40:38 +02:00
parent 08b9bb8095
commit 18bf4e60d1
13 changed files with 747 additions and 97 deletions
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
import { Alert, AlertDescription, AlertIcon, Button, FormControl, FormLabel, Input, Spinner, Stack, Text } from "./chakra-compat";
import { FcGoogle } from "react-icons/fc";
import type { AccountInviteTokenState } from "./types";
type InviteAcceptFormTexts = {
loadingLabel: string;
invalidLinkLabel: string;
emailLabel: string;
nameLabel: string;
passwordLabel: string;
passwordConfirmLabel: string;
submitLabel: string;
googleLabel: string;
};
type InviteAcceptFormProps = {
texts: InviteAcceptFormTexts;
tokenState: AccountInviteTokenState;
loading?: boolean;
initialName?: string | null;
showGoogleSignIn?: boolean;
googleLoading?: boolean;
onSubmit: (values: { name: string; password: string; passwordConfirm: string }) => void | Promise<void>;
onGoogleSignIn?: () => void | Promise<void>;
};
export function InviteAcceptForm({
texts,
tokenState,
loading = false,
initialName = null,
showGoogleSignIn = false,
googleLoading = false,
onSubmit,
onGoogleSignIn
}: InviteAcceptFormProps) {
const [name, setName] = useState(initialName ?? "");
useEffect(() => {
setName(initialName ?? "");
}, [initialName, tokenState.status]);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
void onSubmit({
name: String(form.get("name") ?? ""),
password: String(form.get("password") ?? ""),
passwordConfirm: String(form.get("passwordConfirm") ?? "")
});
}
if (tokenState.status === "loading") {
return (
<Stack align="center" py={6} gap={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>
);
}
return (
<Stack gap={4}>
<FormControl>
<FormLabel>{texts.emailLabel}</FormLabel>
<Input value={tokenState.email} readOnly disabled />
</FormControl>
<form onSubmit={handleSubmit}>
<Stack gap={4}>
<FormControl isRequired>
<FormLabel>{texts.nameLabel}</FormLabel>
<Input name="name" value={name} onChange={(event: ChangeEvent<HTMLInputElement>) => setName(event.target.value)} />
</FormControl>
<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" loading={loading}>
{texts.submitLabel}
</Button>
</Stack>
</form>
{showGoogleSignIn && onGoogleSignIn ? (
<Button variant="outline" loading={googleLoading} leftIcon={<FcGoogle />} onClick={() => void onGoogleSignIn()}>
{texts.googleLabel}
</Button>
) : null}
</Stack>
);
}
+30
View File
@@ -23,6 +23,12 @@ type PasswordResetValidationPayload = {
error?: string;
};
type AccountInviteValidationPayload = {
email?: string;
name?: string | null;
error?: string;
};
type JsonErrorPayload = {
error?: string;
};
@@ -131,6 +137,30 @@ export function createAuthClient(options: CreateAuthClientOptions) {
}
},
async validateAccountInviteToken(token: string): Promise<{ email: string; name: string | null }> {
const response = await request(`/api/auth/invite/validate?token=${encodeURIComponent(token)}`, {
headers: {}
});
const payload = (await response.json().catch(() => null)) as AccountInviteValidationPayload | null;
if (!response.ok || !payload?.email) {
throw new Error(payload?.error ?? "Invalid invite link");
}
return {
email: payload.email,
name: payload.name ?? null
};
},
async acceptAccountInvite(input: { token: string; name: string; password: string }): Promise<void> {
const response = await request("/api/auth/invite/accept", {
method: "POST",
body: JSON.stringify(input)
});
if (!response.ok) {
throw await readJsonError(response, "Invalid invite link");
}
},
async logout(): Promise<void> {
const response = await request("/api/auth/logout", {
method: "POST"
+2
View File
@@ -1,8 +1,10 @@
export { AuthGuard } from "./AuthGuard";
export { createAuthClient } from "./client";
export { InviteAcceptForm } from "./InviteAcceptForm";
export { LoginForm } from "./LoginForm";
export { PasswordResetConfirmForm, PasswordResetRequestForm } from "./PasswordResetForms";
export type {
AccountInviteTokenState,
AuthProviderAvailability,
AuthProviderKey,
AuthSubmitValues,
+5
View File
@@ -17,3 +17,8 @@ export type PasswordResetTokenState =
| { status: "loading" }
| { status: "invalid"; error: string }
| { status: "valid"; email: string; mode: PasswordResetMode };
export type AccountInviteTokenState =
| { status: "loading" }
| { status: "invalid"; error: string }
| { status: "valid"; email: string; name: string | null };