261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
import { Form, useNavigation, useSearchParams } from "react-router";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMemo, useState } from "react";
|
|
import { useRootData } from "~/utils/data/useRootData";
|
|
import LoadingButton from "~/components/ui/buttons/LoadingButton";
|
|
import ExclamationTriangleIcon from "~/components/ui/icons/ExclamationTriangleIcon";
|
|
import Captcha from "~/components/captcha/Captcha";
|
|
import InputText from "~/components/ui/input/InputText";
|
|
|
|
export type PasswordPolicy = {
|
|
minLength: number;
|
|
requireUpper: boolean;
|
|
requireLower: boolean;
|
|
requireDigit: boolean;
|
|
requireSpecial: boolean;
|
|
};
|
|
|
|
interface Props {
|
|
requireCaptcha?: boolean;
|
|
isVerifyingEmail?: boolean;
|
|
isSettingUpAccount?: boolean;
|
|
data?: { company?: string; firstName?: string; lastName?: string; email?: string; slug?: string };
|
|
error: string | undefined;
|
|
/**
|
|
* Marketing-funnel Phase 2 (Phase 05 #79). When present, hidden form
|
|
* inputs are emitted so the register action can route the submission
|
|
* through `convertLeadToTenant` (atomic Tenant + Lead-status update +
|
|
* TenantAcquisition + outbox event). Email is rendered read-only to
|
|
* prevent lead-hijacking ("I use your token but with my email").
|
|
*/
|
|
leadConversion?: { leadId: string; token: string; planId?: string | null };
|
|
passwordPolicy?: PasswordPolicy;
|
|
}
|
|
|
|
export const RegisterForm = ({
|
|
requireCaptcha = false,
|
|
isVerifyingEmail = false,
|
|
isSettingUpAccount = false,
|
|
data = {},
|
|
error,
|
|
leadConversion,
|
|
passwordPolicy,
|
|
}: Props) => {
|
|
const { t } = useTranslation();
|
|
const { appConfiguration, csrf } = useRootData();
|
|
const navigation = useNavigation();
|
|
const [password, setPassword] = useState("");
|
|
|
|
const [searchParams] = useSearchParams();
|
|
const redirect = searchParams.get("redirect") ?? undefined;
|
|
const showPasswordInput = !appConfiguration.auth.requireEmailVerification || isVerifyingEmail || isSettingUpAccount;
|
|
const passwordChecks = useMemo(() => getPasswordChecks(password, passwordPolicy), [password, passwordPolicy]);
|
|
|
|
return (
|
|
<div className="border-border mx-auto flex flex-col items-center space-y-6 rounded-lg border p-6">
|
|
{appConfiguration.auth.authMethods.emailPassword.enabled && (
|
|
<Form method="post" className="w-full space-y-3" data-ui-id="ui.auth.register.form">
|
|
<input type="hidden" name="redirectTo" value={redirect} hidden readOnly />
|
|
<input type="hidden" name="csrf" value={csrf} hidden readOnly />
|
|
{leadConversion ? (
|
|
<>
|
|
<input type="hidden" name="leadId" value={leadConversion.leadId} hidden readOnly />
|
|
<input type="hidden" name="leadToken" value={leadConversion.token} hidden readOnly />
|
|
{leadConversion.planId ? (
|
|
<input type="hidden" name="leadPlanId" value={leadConversion.planId} hidden readOnly />
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
{/* Tenant */}
|
|
{appConfiguration.auth.requireOrganization && (
|
|
<div>
|
|
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="company">
|
|
{t("models.tenant.object")}
|
|
</label> */}
|
|
<InputText
|
|
title={t("models.tenant.object")}
|
|
disabled={navigation.state !== "idle"}
|
|
autoFocus
|
|
type="text"
|
|
name="company"
|
|
dataUiId="ui.auth.register.company"
|
|
id="company"
|
|
placeholder={t("account.shared.companyPlaceholder")}
|
|
required
|
|
defaultValue={data.company}
|
|
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200 sm:text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
{/* Tenant: End */}
|
|
|
|
{/* Personal Info */}
|
|
{appConfiguration.auth.requireName && (
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
<div>
|
|
{/* <label htmlFor="first-name" className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground">
|
|
{t("account.shared.name")}
|
|
</label> */}
|
|
<InputText
|
|
title={t("account.shared.name")}
|
|
type="text"
|
|
name="first-name"
|
|
dataUiId="ui.auth.register.firstName"
|
|
id="first-name"
|
|
required
|
|
defaultValue={data.firstName}
|
|
className="appearance-none rounded-md"
|
|
placeholder={t("account.shared.firstNamePlaceholder")}
|
|
disabled={navigation.state !== "idle"}
|
|
/>
|
|
</div>
|
|
<div>
|
|
{/* <label htmlFor="last-name" className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground">
|
|
<span className="sr-only">{t("models.user.lastName")}</span>
|
|
</label> */}
|
|
<InputText
|
|
title=""
|
|
type="text"
|
|
name="last-name"
|
|
dataUiId="ui.auth.register.lastName"
|
|
id="last-name"
|
|
defaultValue={data.lastName}
|
|
required
|
|
className="appearance-none rounded-md"
|
|
placeholder={t("account.shared.lastNamePlaceholder")}
|
|
disabled={navigation.state !== "idle"}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="email">
|
|
{t("account.shared.email")}
|
|
</label> */}
|
|
<InputText
|
|
type="email"
|
|
title={t("account.shared.email")}
|
|
id="email"
|
|
name="email"
|
|
dataUiId="ui.auth.register.email"
|
|
autoComplete="email"
|
|
required
|
|
readOnly={isVerifyingEmail || Boolean(leadConversion)}
|
|
defaultValue={data.email}
|
|
// className={clsx(
|
|
// "focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200 sm:text-sm",
|
|
// isVerifyingEmail && "cursor-not-allowed bg-secondary/90 dark:bg-slate-800"
|
|
// )}
|
|
placeholder={t("account.shared.emailPlaceholder")}
|
|
disabled={navigation.state !== "idle"}
|
|
/>
|
|
</div>
|
|
|
|
{showPasswordInput && (
|
|
<div>
|
|
{/* <label className="flex justify-between space-x-2 text-xs font-medium text-foreground/80 dark:text-muted-foreground" htmlFor="password">
|
|
{t("account.shared.password")}
|
|
</label> */}
|
|
<InputText
|
|
title={t("account.shared.password")}
|
|
type="password"
|
|
id="password"
|
|
name="password"
|
|
dataUiId="ui.auth.register.password"
|
|
required
|
|
placeholder="************"
|
|
// className="focus:border-theme-500 focus:ring-ring relative mt-1 block w-full appearance-none rounded-md border border-border px-3 py-2 text-sm text-foreground placeholder-gray-500 focus:z-10 focus:outline-hidden dark:border-gray-700 dark:bg-gray-900 dark:text-slate-200 sm:text-sm"
|
|
disabled={navigation.state !== "idle"}
|
|
value={password}
|
|
setValue={setPassword}
|
|
/>
|
|
{passwordPolicy ? (
|
|
<ul
|
|
className="mt-2 space-y-1 text-xs"
|
|
aria-live="polite"
|
|
aria-label={t("account.register.passwordRequirements.label")}
|
|
data-ui-id="ui.auth.register.passwordRequirements"
|
|
>
|
|
{passwordChecks.map((check) => {
|
|
const label = t(check.label, check.args);
|
|
const status = check.valid ? t("account.register.passwordRequirements.met") : t("account.register.passwordRequirements.notMet");
|
|
return (
|
|
<li key={check.key} className={check.valid ? "text-emerald-700 dark:text-emerald-300" : "text-muted-foreground"} aria-label={`${status}: ${label}`}>
|
|
<span aria-hidden="true">{check.valid ? "[x]" : "[ ]"}</span> {label}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
{/* Personal Info: End */}
|
|
|
|
<Captcha
|
|
enabled={requireCaptcha && appConfiguration.auth.captcha.enabled}
|
|
instanceUrl={appConfiguration.auth.captcha.instanceUrl}
|
|
sitekey={appConfiguration.auth.captcha.loginSiteKey}
|
|
/>
|
|
|
|
<div>
|
|
<LoadingButton disabled={navigation.state !== "idle"} className="w-full" type="submit" dataUiId="ui.auth.register.submit">
|
|
{t("account.register.prompts.register.title")}
|
|
</LoadingButton>
|
|
</div>
|
|
|
|
<div id="form-error-message">
|
|
{error && navigation.state === "idle" ? (
|
|
<div className="flex items-center justify-center space-x-2 text-sm text-red-600 dark:text-red-300" role="alert">
|
|
<ExclamationTriangleIcon className="h-4 w-4" />
|
|
<div>{error}</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</Form>
|
|
)}
|
|
|
|
<p className="border-border mt-3 border-t py-2 text-center text-sm">
|
|
{t("account.register.bySigningUp")}{" "}
|
|
<a target="_blank" href="/terms-and-conditions" className="text-primary underline">
|
|
{t("account.register.termsAndConditions")}
|
|
</a>{" "}
|
|
{t("account.register.andOur")}{" "}
|
|
<a target="_blank" href="/privacy-policy" className="text-primary underline">
|
|
{t("account.register.privacyPolicy")}
|
|
</a>
|
|
.
|
|
</p>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export function getPasswordChecks(password: string, policy?: PasswordPolicy) {
|
|
if (!policy) {
|
|
return [];
|
|
}
|
|
|
|
const checks: { key: string; valid: boolean; label: string; args: Record<string, string | number> }[] = [
|
|
{
|
|
key: "minLength",
|
|
valid: password.length >= policy.minLength,
|
|
label: "account.register.passwordRequirements.minLength",
|
|
args: { 0: policy.minLength },
|
|
},
|
|
];
|
|
|
|
if (policy.requireUpper) {
|
|
checks.push({ key: "upper", valid: /[A-Z]/.test(password), label: "account.register.passwordRequirements.upper", args: {} });
|
|
}
|
|
if (policy.requireLower) {
|
|
checks.push({ key: "lower", valid: /[a-z]/.test(password), label: "account.register.passwordRequirements.lower", args: {} });
|
|
}
|
|
if (policy.requireDigit) {
|
|
checks.push({ key: "digit", valid: /\d/.test(password), label: "account.register.passwordRequirements.digit", args: {} });
|
|
}
|
|
if (policy.requireSpecial) {
|
|
checks.push({ key: "special", valid: /[^A-Za-z0-9]/.test(password), label: "account.register.passwordRequirements.special", args: {} });
|
|
}
|
|
return checks;
|
|
}
|