feat: otp double sending

This commit is contained in:
Nathnael
2026-07-20 12:10:49 +00:00
parent aa02700e4c
commit 0549a88d57
17 changed files with 741 additions and 461 deletions

View File

@@ -1,70 +1,20 @@
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
import {
AlertCircle,
ArrowLeft,
Mail,
RotateCw,
ShieldCheck,
Smartphone,
} from "lucide-react";
import { Alert, Button, PinInput, Stack, Text } from "@mantine/core";
import { AlertCircle, ArrowLeft, RotateCw, ShieldCheck } from "lucide-react";
import { maskEmail, maskPhone } from "@/utils/identifier";
export type OtpChannel = "phone" | "email";
export const OTP_LENGTH = 6;
export interface OtpChannelSelectProps {
value: OtpChannel;
onChange: (channel: OtpChannel) => void;
disabled?: boolean;
label?: string;
}
/** Phone/email toggle deciding where the verification code is sent. */
export function OtpChannelSelect({
value,
onChange,
disabled,
label = "Send verification code via",
}: OtpChannelSelectProps) {
return (
<div className="space-y-1.5">
<Text size="sm" fw={500} c="edr-text">
{label}
</Text>
<SegmentedControl
fullWidth
disabled={disabled}
value={value}
onChange={(v) => onChange(v as OtpChannel)}
data={[
{
value: "phone",
label: (
<span className="flex items-center justify-center gap-1.5">
<Smartphone size={14} /> Phone
</span>
),
},
{
value: "email",
label: (
<span className="flex items-center justify-center gap-1.5">
<Mail size={14} /> Email
</span>
),
},
]}
/>
</div>
);
}
export interface OtpChannelStepProps {
channel: OtpChannel;
/** Raw email or phone the code went to; masked before display. */
target: string;
/**
* 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;
@@ -82,11 +32,13 @@ export interface OtpChannelStepProps {
/**
* The "enter the code we sent you" stage. Shared by signup and the
* forgot-password flow — both send through the same `/api/otp/*` service.
* 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({
channel,
target,
email,
phone,
value,
onChange,
onVerify,
@@ -100,7 +52,10 @@ export default function OtpChannelStep({
description,
submitLabel,
}: OtpChannelStepProps) {
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
const maskedTargets = [
email ? maskEmail(email) : null,
phone ? maskPhone(phone) : null,
].filter(Boolean) as string[];
const busy = sending || verifying;
return (
@@ -113,12 +68,23 @@ export default function OtpChannelStep({
<div className="space-y-1.5 text-center">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
{title ?? `Verify your ${channel === "email" ? "email" : "phone"}`}
{title ?? "Verify it's you"}
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a {OTP_LENGTH}-digit code to{" "}
<span className="font-medium text-gray-700">{maskedTarget}</span>.{" "}
{description ?? "Enter it to continue."}
{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>