Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/users/components/RegisterForm.tsx
T

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;
}