235 lines
10 KiB
TypeScript
235 lines
10 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Form, Link, useNavigation, useSearchParams } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FeatherMail } from "@subframe/core";
|
|
import { useRootData } from "~/utils/data/useRootData";
|
|
import { AuthShell, AuthStatus } from "~/components/auth/AuthShell";
|
|
import { Button } from "~/ui/components/Button";
|
|
import { Checkbox } from "~/ui/components/Checkbox";
|
|
import { LinkButton } from "~/ui/components/LinkButton";
|
|
import { Progress } from "~/ui/components/Progress";
|
|
import { TextInput } from "~/ui/components/TextInput";
|
|
import Captcha from "~/components/captcha/Captcha";
|
|
import { getPasswordChecks, type PasswordPolicy } from "~/modules/users/components/RegisterForm";
|
|
|
|
// Signup-Screen nach Davids Subframe-Design ("Signup"): Logo + Card (Konto erstellen) + E-Mail, Passwort
|
|
// (+ Staerke-Progress), Firmenname, Vor-/Nachname, Telefon, USt-IdNr, 3 Consent-Checkboxen + "Konto erstellen".
|
|
// Voll an die bestehende Register-Action verdrahtet (gleiche Feld-Namen, Password-Policy, Captcha, Lead-Felder).
|
|
// verificationEmailSent -> Davids Success-Status. Footer "Schon ein Konto? Anmelden".
|
|
//
|
|
// 💡 Backend-Luecken (gerendert + gepostet, aber von registerFromRequest noch nicht persistiert):
|
|
// - phone: User-Modell hat die Spalte (accounts.prisma) -> Action muss das Feld nur noch lesen.
|
|
// - vatNumber (USt-IdNr): keine Spalte im LOCKED-Modell -> braucht Data-Model-Entscheidung.
|
|
// - 3 Consents: Backend nutzt impliziten Terms-Consent; hier als Client-Gate (kein Server-Tracking).
|
|
|
|
type RegisterData = { company?: string; firstName?: string; lastName?: string; email?: string; slug?: string };
|
|
|
|
export default function RegisterScreen({
|
|
requireCaptcha = false,
|
|
error,
|
|
verificationEmailSent,
|
|
data = {},
|
|
leadConversion,
|
|
passwordPolicy,
|
|
}: {
|
|
requireCaptcha?: boolean;
|
|
error?: string;
|
|
verificationEmailSent?: boolean;
|
|
data?: RegisterData;
|
|
leadConversion?: { leadId: string; token: string; planId?: string | null };
|
|
passwordPolicy?: PasswordPolicy;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { appConfiguration, csrf } = useRootData();
|
|
const navigation = useNavigation();
|
|
const [searchParams] = useSearchParams();
|
|
const isSubmitting = navigation.state !== "idle";
|
|
const redirect = searchParams.get("redirect") ?? "";
|
|
|
|
const [password, setPassword] = useState("");
|
|
const [acceptTerms, setAcceptTerms] = useState(false);
|
|
const [acceptPrivacy, setAcceptPrivacy] = useState(false);
|
|
const [acceptDpa, setAcceptDpa] = useState(false);
|
|
const allConsents = acceptTerms && acceptPrivacy && acceptDpa;
|
|
|
|
const showPasswordInput = !appConfiguration.auth.requireEmailVerification;
|
|
const checks = useMemo(() => getPasswordChecks(password, passwordPolicy), [password, passwordPolicy]);
|
|
const strength = checks.length === 0 ? 0 : Math.round((checks.filter((c) => c.valid).length / checks.length) * 100);
|
|
|
|
if (verificationEmailSent) {
|
|
return (
|
|
<AuthStatus
|
|
icon={<FeatherMail />}
|
|
tone="positive"
|
|
title={t("account.verify.title")}
|
|
message={t("account.verify.emailSent")}
|
|
dataUiId="ui.auth.register.verificationSent"
|
|
>
|
|
<Link to="/login" className="no-underline">
|
|
<LinkButton size="small">{t("account.login.backToLogin")}</LinkButton>
|
|
</Link>
|
|
</AuthStatus>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AuthShell
|
|
title={t("account.register.title")}
|
|
subtitle={t("account.register.subtitle")}
|
|
cardClassName="max-w-[480px] gap-5 shadow-lg"
|
|
dataUiId="ui.auth.register.page"
|
|
footer={
|
|
<>
|
|
<span className="text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
|
|
{t("account.register.haveAccount")}
|
|
</span>
|
|
<Link to="/login" className="no-underline" data-ui-id="ui.auth.register.login">
|
|
<LinkButton size="small" hideIcon>
|
|
{t("account.register.signIn")}
|
|
</LinkButton>
|
|
</Link>
|
|
</>
|
|
}
|
|
>
|
|
{appConfiguration.auth.authMethods.emailPassword.enabled ? (
|
|
<Form method="post" noValidate className="flex w-full flex-col gap-5" data-ui-id="ui.auth.register.form">
|
|
<input type="hidden" name="redirectTo" value={redirect} readOnly />
|
|
<input type="hidden" name="csrf" value={csrf} readOnly />
|
|
{leadConversion ? (
|
|
<>
|
|
<input type="hidden" name="leadId" value={leadConversion.leadId} readOnly />
|
|
<input type="hidden" name="leadToken" value={leadConversion.token} readOnly />
|
|
{leadConversion.planId ? <input type="hidden" name="leadPlanId" value={leadConversion.planId} readOnly /> : null}
|
|
</>
|
|
) : null}
|
|
|
|
{/* Davids Felder-Gruppe: alle Inputs in einem gap-4-Wrapper; Form-gap-5 trennt Gruppe/Consent/Button. */}
|
|
<div className="flex w-full flex-col items-start gap-4">
|
|
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.shared.email")} type="email">
|
|
<TextInput.Input
|
|
name="email"
|
|
type="email"
|
|
required
|
|
autoComplete="email"
|
|
placeholder={t("account.shared.emailPlaceholder")}
|
|
defaultValue={data.email}
|
|
readOnly={Boolean(leadConversion)}
|
|
disabled={isSubmitting}
|
|
data-ui-id="ui.auth.register.email"
|
|
/>
|
|
</TextInput>
|
|
|
|
{showPasswordInput ? (
|
|
<>
|
|
<div className="flex w-full flex-col items-start gap-2">
|
|
<div className="flex w-full items-end gap-2">
|
|
<TextInput className="h-auto grow shrink-0 basis-0" fillContainer showLabel label={t("account.shared.password")} type="password">
|
|
<TextInput.Input
|
|
name="password"
|
|
type="password"
|
|
required
|
|
autoComplete="new-password"
|
|
placeholder={t("account.shared.password")}
|
|
value={password}
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
disabled={isSubmitting}
|
|
data-ui-id="ui.auth.register.password"
|
|
/>
|
|
</TextInput>
|
|
</div>
|
|
{passwordPolicy ? (
|
|
<>
|
|
<Progress className="h-auto w-full flex-none" hideLabel value={strength} />
|
|
<span className="text-body-small-medium-0 font-body-small-medium-0 text-text-neutral-tertiary">
|
|
{t("account.register.passwordStrength")}
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
{passwordPolicy ? (
|
|
<span className="w-full text-body-small-regular-0 font-body-small-regular-0 text-text-neutral-tertiary">
|
|
{t("account.register.passwordHint")}
|
|
</span>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
|
|
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.register.companyLabel")}>
|
|
<TextInput.Input
|
|
name="company"
|
|
required={appConfiguration.auth.requireOrganization}
|
|
placeholder={t("account.shared.companyPlaceholder")}
|
|
defaultValue={data.company}
|
|
disabled={isSubmitting}
|
|
data-ui-id="ui.auth.register.company"
|
|
/>
|
|
</TextInput>
|
|
|
|
<div className="flex w-full items-start gap-4 mobile:flex-col">
|
|
<TextInput className="h-auto grow shrink-0 basis-0" showLabel label={t("models.user.firstName")}>
|
|
<TextInput.Input
|
|
name="first-name"
|
|
placeholder={t("account.shared.firstNamePlaceholder")}
|
|
defaultValue={data.firstName}
|
|
disabled={isSubmitting}
|
|
data-ui-id="ui.auth.register.firstName"
|
|
/>
|
|
</TextInput>
|
|
<TextInput className="h-auto grow shrink-0 basis-0" showLabel label={t("models.user.lastName")}>
|
|
<TextInput.Input
|
|
name="last-name"
|
|
placeholder={t("account.shared.lastNamePlaceholder")}
|
|
defaultValue={data.lastName}
|
|
disabled={isSubmitting}
|
|
data-ui-id="ui.auth.register.lastName"
|
|
/>
|
|
</TextInput>
|
|
</div>
|
|
|
|
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.register.phone")} type="tel">
|
|
<TextInput.Input
|
|
name="phone"
|
|
type="tel"
|
|
placeholder={t("account.register.phonePlaceholder")}
|
|
disabled={isSubmitting}
|
|
data-ui-id="ui.auth.register.phone"
|
|
/>
|
|
</TextInput>
|
|
|
|
<TextInput className="h-auto w-full flex-none" fillContainer showLabel label={t("account.register.vatId")}>
|
|
<TextInput.Input
|
|
name="vatNumber"
|
|
placeholder={t("account.register.vatIdPlaceholder")}
|
|
disabled={isSubmitting}
|
|
data-ui-id="ui.auth.register.vat"
|
|
/>
|
|
</TextInput>
|
|
</div>
|
|
|
|
<div className="flex w-full flex-col items-start gap-3">
|
|
<Checkbox label={t("account.register.consentTerms")} checked={acceptTerms} onCheckedChange={setAcceptTerms} />
|
|
<Checkbox label={t("account.register.consentPrivacy")} checked={acceptPrivacy} onCheckedChange={setAcceptPrivacy} />
|
|
<Checkbox label={t("account.register.consentDpa")} checked={acceptDpa} onCheckedChange={setAcceptDpa} />
|
|
</div>
|
|
|
|
<Captcha
|
|
enabled={requireCaptcha && appConfiguration.auth.captcha.enabled}
|
|
instanceUrl={appConfiguration.auth.captcha.instanceUrl}
|
|
sitekey={appConfiguration.auth.captcha.loginSiteKey}
|
|
/>
|
|
|
|
<Button type="submit" loading={isSubmitting} disabled={isSubmitting || !allConsents} data-ui-id="ui.auth.register.submit">
|
|
{t("account.register.title")}
|
|
</Button>
|
|
|
|
{error && navigation.state === "idle" ? (
|
|
<p className="text-center text-body-small-regular-0 font-body-small-regular-0 text-text-feedback-negative" role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
</Form>
|
|
) : null}
|
|
</AuthShell>
|
|
);
|
|
}
|