Files
edr-platform/apps/edr-freight-web/backoffice/src/components/auth/OtpChannelStep.tsx
2026-07-20 12:10:49 +00:00

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