mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
146 lines
4.0 KiB
TypeScript
146 lines
4.0 KiB
TypeScript
import { Alert, Button, PinInput, Stack, Text } from "@mantine/core";
|
|
import { AlertCircle, ArrowLeft, RotateCw, ShieldCheck } from "lucide-react";
|
|
|
|
import { maskEmail, maskPhone } from "@/utils/identifier";
|
|
|
|
export const OTP_LENGTH = 6;
|
|
|
|
export interface OtpChannelStepProps {
|
|
/**
|
|
* Raw contacts the code was sent to; masked before display. The API sends one
|
|
* code to every contact on the account, so both are usually set — pass only
|
|
* what the client actually knows. Omit both when the client cannot know them
|
|
* (the forgot-password flow deliberately never reveals an account's contacts)
|
|
* and a generic line is shown instead.
|
|
*/
|
|
email?: string;
|
|
phone?: string;
|
|
value: string;
|
|
onChange: (otp: string) => void;
|
|
onVerify: () => void;
|
|
onBack: () => void;
|
|
onResend: () => void;
|
|
/** Seconds until resend is allowed; 0 enables the button. */
|
|
resendIn: number;
|
|
sending: boolean;
|
|
verifying: boolean;
|
|
error: string | null;
|
|
title?: string;
|
|
description?: string;
|
|
submitLabel: string;
|
|
}
|
|
|
|
/**
|
|
* The "enter the code we sent you" stage. Shared by signup and the
|
|
* forgot-password flow — both send through the same `/api/otp/*` service, which
|
|
* delivers a single code to the account's email AND phone; whichever message
|
|
* arrives first can be typed here.
|
|
*/
|
|
export default function OtpChannelStep({
|
|
email,
|
|
phone,
|
|
value,
|
|
onChange,
|
|
onVerify,
|
|
onBack,
|
|
onResend,
|
|
resendIn,
|
|
sending,
|
|
verifying,
|
|
error,
|
|
title,
|
|
description,
|
|
submitLabel,
|
|
}: OtpChannelStepProps) {
|
|
const maskedTargets = [
|
|
email ? maskEmail(email) : null,
|
|
phone ? maskPhone(phone) : null,
|
|
].filter(Boolean) as string[];
|
|
const busy = sending || verifying;
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
<div className="mb-1 flex justify-center">
|
|
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
<ShieldCheck size={22} />
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 text-center">
|
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
|
{title ?? "Verify it's you"}
|
|
</h1>
|
|
<p className="text-sm leading-relaxed text-gray-500">
|
|
We sent a {OTP_LENGTH}-digit code to{" "}
|
|
{maskedTargets.length ? (
|
|
maskedTargets.map((target, index) => (
|
|
<span key={target}>
|
|
{index > 0 ? " and " : null}
|
|
<span className="font-medium text-gray-700">{target}</span>
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="font-medium text-gray-700">
|
|
the email and phone on your account
|
|
</span>
|
|
)}
|
|
. {description ?? "Enter it to continue."}
|
|
</p>
|
|
</div>
|
|
|
|
{error ? (
|
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
|
{error}
|
|
</Alert>
|
|
) : null}
|
|
|
|
<Stack gap={6} align="center">
|
|
<Text size="sm" fw={500} c="edr-text">
|
|
Verification code
|
|
</Text>
|
|
<PinInput
|
|
length={OTP_LENGTH}
|
|
type="number"
|
|
oneTimeCode
|
|
value={value}
|
|
placeholder="0"
|
|
disabled={verifying}
|
|
styles={{ input: { textAlign: "center" } }}
|
|
onChange={onChange}
|
|
/>
|
|
</Stack>
|
|
|
|
<Button
|
|
color="edr-green"
|
|
fullWidth
|
|
loading={verifying}
|
|
disabled={verifying || value.trim().length !== OTP_LENGTH}
|
|
onClick={onVerify}
|
|
>
|
|
{submitLabel}
|
|
</Button>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Button
|
|
variant="subtle"
|
|
color="gray"
|
|
leftSection={<ArrowLeft size={14} />}
|
|
disabled={busy}
|
|
onClick={onBack}
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
variant="subtle"
|
|
color="edr-green"
|
|
leftSection={<RotateCw size={14} />}
|
|
disabled={resendIn > 0 || busy}
|
|
onClick={onResend}
|
|
>
|
|
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
|
</Button>
|
|
</div>
|
|
</Stack>
|
|
);
|
|
}
|