Merge claude/register-fixes into main

Persist values, a11y errors, and aria-invalid on /register
This commit is contained in:
t.indorf
2026-05-17 23:53:38 +02:00
2 changed files with 97 additions and 13 deletions
+31 -3
View File
@@ -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 };
} }
+66 -10
View File
@@ -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>
); );