Merge claude/register-fixes into main
Persist values, a11y errors, and aria-invalid on /register
This commit is contained in:
@@ -32,6 +32,16 @@ const registerSchema = z.object({
|
|||||||
const PRIVACY_POLICY_VERSION = "2026-05-01";
|
const PRIVACY_POLICY_VERSION = "2026-05-01";
|
||||||
|
|
||||||
export type RegisterState = {
|
export type RegisterState = {
|
||||||
|
// Bumped after every action call so the client can key uncontrolled inputs
|
||||||
|
// and force a remount with the echoed values from a failed submit.
|
||||||
|
attempt: number;
|
||||||
|
// Echoed inputs (sans password) so failed submits don't wipe the form.
|
||||||
|
values?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
acceptPrivacyPolicy?: boolean;
|
||||||
|
};
|
||||||
errors?: {
|
errors?: {
|
||||||
email?: string[];
|
email?: string[];
|
||||||
password?: string[];
|
password?: string[];
|
||||||
@@ -42,13 +52,29 @@ export type RegisterState = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const initialRegisterState: RegisterState = { attempt: 0 };
|
||||||
|
|
||||||
|
function echoValues(formData: FormData): RegisterState["values"] {
|
||||||
|
return {
|
||||||
|
firstName: String(formData.get("firstName") ?? ""),
|
||||||
|
lastName: String(formData.get("lastName") ?? ""),
|
||||||
|
email: String(formData.get("email") ?? ""),
|
||||||
|
acceptPrivacyPolicy: formData.get("acceptPrivacyPolicy") === "on",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerAction(
|
export async function registerAction(
|
||||||
_prevState: RegisterState,
|
prevState: RegisterState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<RegisterState> {
|
): Promise<RegisterState> {
|
||||||
|
const attempt = prevState.attempt + 1;
|
||||||
const parsed = registerSchema.safeParse(Object.fromEntries(formData));
|
const parsed = registerSchema.safeParse(Object.fromEntries(formData));
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return { errors: parsed.error.flatten().fieldErrors };
|
return {
|
||||||
|
attempt,
|
||||||
|
values: echoValues(formData),
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const { email, password, firstName, lastName } = parsed.data;
|
const { email, password, firstName, lastName } = parsed.data;
|
||||||
|
|
||||||
@@ -77,6 +103,8 @@ export async function registerAction(
|
|||||||
err.code === "P2002"
|
err.code === "P2002"
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
|
attempt,
|
||||||
|
values: echoValues(formData),
|
||||||
errors: {
|
errors: {
|
||||||
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
|
email: ["Mit dieser E-Mail-Adresse existiert bereits ein Account."],
|
||||||
},
|
},
|
||||||
@@ -93,5 +121,5 @@ export async function registerAction(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Unreachable – signIn redirected.
|
// Unreachable – signIn redirected.
|
||||||
return {};
|
return { attempt };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,45 +7,58 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
import { registerAction, type RegisterState } from "./actions";
|
import { initialRegisterState, registerAction } from "./actions";
|
||||||
|
|
||||||
const initialState: RegisterState = {};
|
|
||||||
|
|
||||||
export function RegisterForm() {
|
export function RegisterForm() {
|
||||||
const [state, formAction, pending] = useActionState(registerAction, initialState);
|
const [state, formAction, pending] = useActionState(
|
||||||
|
registerAction,
|
||||||
|
initialRegisterState,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bumping the key on each attempt forces uncontrolled inputs to remount with
|
||||||
|
// the echoed defaultValue from the server — otherwise React's <form action>
|
||||||
|
// semantics would wipe them after a failed submit.
|
||||||
|
const attemptKey = state.attempt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={formAction} className="space-y-4">
|
<form action={formAction} className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
|
key={`firstName-${attemptKey}`}
|
||||||
id="firstName"
|
id="firstName"
|
||||||
name="firstName"
|
name="firstName"
|
||||||
label="Vorname"
|
label="Vorname"
|
||||||
autoComplete="given-name"
|
autoComplete="given-name"
|
||||||
required
|
required
|
||||||
|
defaultValue={state.values?.firstName ?? ""}
|
||||||
error={state.errors?.firstName?.[0]}
|
error={state.errors?.firstName?.[0]}
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
|
key={`lastName-${attemptKey}`}
|
||||||
id="lastName"
|
id="lastName"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
label="Nachname"
|
label="Nachname"
|
||||||
autoComplete="family-name"
|
autoComplete="family-name"
|
||||||
required
|
required
|
||||||
|
defaultValue={state.values?.lastName ?? ""}
|
||||||
error={state.errors?.lastName?.[0]}
|
error={state.errors?.lastName?.[0]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
key={`email-${attemptKey}`}
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
label="E-Mail"
|
label="E-Mail"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
|
defaultValue={state.values?.email ?? ""}
|
||||||
error={state.errors?.email?.[0]}
|
error={state.errors?.email?.[0]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
|
key={`password-${attemptKey}`}
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -57,23 +70,51 @@ export function RegisterForm() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-start gap-3 pt-2">
|
<div className="flex items-start gap-3 pt-2">
|
||||||
<Checkbox id="acceptPrivacyPolicy" name="acceptPrivacyPolicy" required />
|
<Checkbox
|
||||||
|
key={`acceptPrivacyPolicy-${attemptKey}`}
|
||||||
|
id="acceptPrivacyPolicy"
|
||||||
|
name="acceptPrivacyPolicy"
|
||||||
|
required
|
||||||
|
defaultChecked={state.values?.acceptPrivacyPolicy === true}
|
||||||
|
aria-invalid={!!state.errors?.acceptPrivacyPolicy?.[0]}
|
||||||
|
aria-describedby={
|
||||||
|
state.errors?.acceptPrivacyPolicy?.[0]
|
||||||
|
? "acceptPrivacyPolicy-error"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className="grid gap-1 leading-none">
|
<div className="grid gap-1 leading-none">
|
||||||
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
|
<Label htmlFor="acceptPrivacyPolicy" className="text-sm font-normal">
|
||||||
Ich habe die{" "}
|
Ich habe die{" "}
|
||||||
<a href="/datenschutz" className="underline" target="_blank" rel="noreferrer">
|
<a
|
||||||
|
href="/datenschutz"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
Datenschutzerklärung
|
Datenschutzerklärung
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
gelesen und akzeptiere sie.
|
gelesen und akzeptiere sie.
|
||||||
</Label>
|
</Label>
|
||||||
{state.errors?.acceptPrivacyPolicy?.[0] && (
|
{state.errors?.acceptPrivacyPolicy?.[0] && (
|
||||||
<p className="text-xs text-destructive">{state.errors.acceptPrivacyPolicy[0]}</p>
|
<p
|
||||||
|
id="acceptPrivacyPolicy-error"
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
className="text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{state.errors.acceptPrivacyPolicy[0]}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.errors?._form?.[0] && (
|
{state.errors?._form?.[0] && (
|
||||||
<p className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
<p
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||||
|
>
|
||||||
{state.errors._form[0]}
|
{state.errors._form[0]}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -93,6 +134,7 @@ function FormField({
|
|||||||
required,
|
required,
|
||||||
autoComplete,
|
autoComplete,
|
||||||
helperText,
|
helperText,
|
||||||
|
defaultValue,
|
||||||
error,
|
error,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -102,8 +144,11 @@ function FormField({
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
autoComplete?: string;
|
autoComplete?: string;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
|
defaultValue?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const errorId = error ? `${id}-error` : undefined;
|
||||||
|
const helperId = !error && helperText ? `${id}-helper` : undefined;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor={id}>{label}</Label>
|
<Label htmlFor={id}>{label}</Label>
|
||||||
@@ -113,12 +158,23 @@ function FormField({
|
|||||||
type={type}
|
type={type}
|
||||||
required={required}
|
required={required}
|
||||||
autoComplete={autoComplete}
|
autoComplete={autoComplete}
|
||||||
|
defaultValue={defaultValue}
|
||||||
aria-invalid={!!error}
|
aria-invalid={!!error}
|
||||||
|
aria-describedby={errorId ?? helperId}
|
||||||
/>
|
/>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-xs text-destructive">{error}</p>
|
<p
|
||||||
|
id={errorId}
|
||||||
|
role="alert"
|
||||||
|
aria-live="polite"
|
||||||
|
className="text-xs text-destructive"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
) : helperText ? (
|
) : helperText ? (
|
||||||
<p className="text-xs text-muted-foreground">{helperText}</p>
|
<p id={helperId} className="text-xs text-muted-foreground">
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user