mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 00:45:41 +00:00
feat: otp double sending
This commit is contained in:
@@ -58,13 +58,9 @@ export interface LoginResponse extends Partial<AuthTokens> {
|
||||
mfaRequired?: boolean;
|
||||
}
|
||||
|
||||
/** The channel a password-reset code is delivered over. */
|
||||
export type ResetChannel = "email" | "phone";
|
||||
|
||||
export interface ForgotPasswordRequestPayload {
|
||||
/** Email, username, or E.164 phone — whatever the user typed, normalised. */
|
||||
identifier: string;
|
||||
channel: ResetChannel;
|
||||
}
|
||||
|
||||
export interface ForgotPasswordVerifyPayload extends ForgotPasswordRequestPayload {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
} from "@/auth/api";
|
||||
import type { ResetTicket } from "@/auth/types";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import OtpChannelStep, {
|
||||
OTP_LENGTH,
|
||||
OtpChannelSelect,
|
||||
type OtpChannel,
|
||||
} from "@/components/auth/OtpChannelStep";
|
||||
import OtpChannelStep, { OTP_LENGTH } from "@/components/auth/OtpChannelStep";
|
||||
import PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||
import { normaliseIdentifier } from "@/utils/identifier";
|
||||
@@ -28,7 +24,6 @@ const ForgotPasswordPage = () => {
|
||||
|
||||
const [stage, setStage] = useState<Stage>("identify");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [channel, setChannel] = useState<OtpChannel>("phone");
|
||||
const [otpCode, setOtpCode] = useState("");
|
||||
// The reset ticket lives in memory only — persisting it would leave a
|
||||
// password-change credential sitting in localStorage.
|
||||
@@ -45,7 +40,7 @@ const ForgotPasswordPage = () => {
|
||||
const normalised = normaliseIdentifier(identifier);
|
||||
|
||||
const sendCode = async () => {
|
||||
await requestPasswordResetRequest({ identifier: normalised, channel });
|
||||
await requestPasswordResetRequest({ identifier: normalised });
|
||||
setOtpCode("");
|
||||
resendCooldown.start();
|
||||
};
|
||||
@@ -89,7 +84,6 @@ const ForgotPasswordPage = () => {
|
||||
try {
|
||||
const result = await verifyPasswordResetOtpRequest({
|
||||
identifier: normalised,
|
||||
channel,
|
||||
otp: otpCode.trim(),
|
||||
});
|
||||
setTicket(result);
|
||||
@@ -138,13 +132,10 @@ const ForgotPasswordPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const identifierLabel =
|
||||
channel === "email" ? "the email on your account" : "the phone on your account";
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Recover your account"
|
||||
taglineBody="Reset your EDR Freight backoffice password with a one-time code sent to your email or phone."
|
||||
taglineBody="Reset your EDR Freight backoffice password with a one-time code sent to your email and phone."
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
{stage === "identify" ? (
|
||||
@@ -176,16 +167,9 @@ const ForgotPasswordPage = () => {
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<OtpChannelSelect
|
||||
value={channel}
|
||||
onChange={setChannel}
|
||||
disabled={sending}
|
||||
label="Send the code to"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
The code goes to {identifierLabel}, which may differ from what you
|
||||
typed above.
|
||||
The code goes to the email and phone on your account, which may
|
||||
differ from what you typed above.
|
||||
</p>
|
||||
|
||||
{error ? (
|
||||
@@ -217,8 +201,6 @@ const ForgotPasswordPage = () => {
|
||||
|
||||
{stage === "otp" ? (
|
||||
<OtpChannelStep
|
||||
channel={channel}
|
||||
target={normalised}
|
||||
value={otpCode}
|
||||
onChange={setOtpCode}
|
||||
onVerify={handleVerify}
|
||||
|
||||
Reference in New Issue
Block a user