mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 14:38:12 +00:00
feat: setup forget password to the backoffice
This commit is contained in:
@@ -46,6 +46,7 @@ import {
|
|||||||
import { useAuth } from "./auth/useAuth";
|
import { useAuth } from "./auth/useAuth";
|
||||||
import LoadingScreen from "./components/LoadingScreen";
|
import LoadingScreen from "./components/LoadingScreen";
|
||||||
import LoginPage from "./pages/auth/LoginPage";
|
import LoginPage from "./pages/auth/LoginPage";
|
||||||
|
import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
|
||||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||||
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
|
||||||
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
|
||||||
@@ -696,6 +697,7 @@ const App = () => {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth" element={<LoginPage />} />
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||||
<Route path="um/set-password" element={<SetPassword />} />
|
<Route path="um/set-password" element={<SetPassword />} />
|
||||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { api } from "./http";
|
import { api } from "./http";
|
||||||
import type { AuthTokens, AuthUser, LoginResponse } from "./types";
|
import type {
|
||||||
|
AuthTokens,
|
||||||
|
AuthUser,
|
||||||
|
ForgotPasswordRequestPayload,
|
||||||
|
ForgotPasswordVerifyPayload,
|
||||||
|
LoginResponse,
|
||||||
|
ResetTicket,
|
||||||
|
SetPasswordPayload,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const loginRequest = async (payload: {
|
export const loginRequest = async (payload: {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -21,3 +29,31 @@ export const getMeRequest = async () => {
|
|||||||
const response = await api.get<AuthUser>("/me");
|
const response = await api.get<AuthUser>("/me");
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The three calls below drive the unauthenticated forgot-password flow.
|
||||||
|
// Responses under /api/auth are *flattened* by the API's response
|
||||||
|
// interceptor ({ success, ...payload }), so there is no `.data.data` here.
|
||||||
|
|
||||||
|
export const requestPasswordResetRequest = async (
|
||||||
|
payload: ForgotPasswordRequestPayload,
|
||||||
|
) => {
|
||||||
|
await api.post("/auth/forgot-password/request", payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyPasswordResetOtpRequest = async (
|
||||||
|
payload: ForgotPasswordVerifyPayload,
|
||||||
|
) => {
|
||||||
|
const response = await api.post<ResetTicket>(
|
||||||
|
"/auth/forgot-password/verify",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spend the reset ticket minted by {@link verifyPasswordResetOtpRequest}.
|
||||||
|
* Carries its own userId/verificationCode and never touches the session.
|
||||||
|
*/
|
||||||
|
export const resetPasswordRequest = async (payload: SetPasswordPayload) => {
|
||||||
|
await api.patch("/auth/set-password", payload);
|
||||||
|
};
|
||||||
|
|||||||
@@ -58,6 +58,33 @@ export interface LoginResponse extends Partial<AuthTokens> {
|
|||||||
mfaRequired?: boolean;
|
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 {
|
||||||
|
otp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-use ticket to spend on `PATCH /api/auth/set-password`. */
|
||||||
|
export interface ResetTicket {
|
||||||
|
userId: string;
|
||||||
|
verificationCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetPasswordPayload {
|
||||||
|
newPassword: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
verificationCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Additional types for Matrix form test
|
// Additional types for Matrix form test
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { Alert, Button, PinInput, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowLeft,
|
||||||
|
Mail,
|
||||||
|
RotateCw,
|
||||||
|
ShieldCheck,
|
||||||
|
Smartphone,
|
||||||
|
} 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;
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
export default function OtpChannelStep({
|
||||||
|
channel,
|
||||||
|
target,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onVerify,
|
||||||
|
onBack,
|
||||||
|
onResend,
|
||||||
|
resendIn,
|
||||||
|
sending,
|
||||||
|
verifying,
|
||||||
|
error,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
submitLabel,
|
||||||
|
}: OtpChannelStepProps) {
|
||||||
|
const maskedTarget = channel === "email" ? maskEmail(target) : maskPhone(target);
|
||||||
|
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 your ${channel === "email" ? "email" : "phone"}`}
|
||||||
|
</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."}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { passwordRequirements } from "@/utils/passwordSchema";
|
||||||
|
|
||||||
|
export interface PasswordChecklistProps {
|
||||||
|
/** The current password value; the checklist hides itself when empty. */
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live pass/fail list of the password rules, shown under a password field. */
|
||||||
|
export default function PasswordChecklist({ value }: PasswordChecklistProps) {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{passwordRequirements.map((req) => {
|
||||||
|
const met = req.test(value);
|
||||||
|
return (
|
||||||
|
<div key={req.label} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||||
|
met
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-gray-200 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{met ? (
|
||||||
|
<Check className="h-2.5 w-2.5" />
|
||||||
|
) : (
|
||||||
|
<X className="h-2.5 w-2.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||||
|
{req.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** Seconds a user must wait before another OTP can be requested. */
|
||||||
|
const DEFAULT_COOLDOWN_SECONDS = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Countdown that gates the "Resend code" button. Ticks with setTimeout rather
|
||||||
|
* than wall-clock arithmetic, so it needs no Date.now().
|
||||||
|
*/
|
||||||
|
export function useResendCooldown(seconds: number = DEFAULT_COOLDOWN_SECONDS) {
|
||||||
|
const [secondsLeft, setSecondsLeft] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (secondsLeft <= 0) return;
|
||||||
|
const t = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [secondsLeft]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
secondsLeft,
|
||||||
|
start: () => setSecondsLeft(seconds),
|
||||||
|
reset: () => setSecondsLeft(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||||
|
import { AlertCircle, ArrowLeft, ArrowRight, KeyRound } from "lucide-react";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import {
|
||||||
|
requestPasswordResetRequest,
|
||||||
|
resetPasswordRequest,
|
||||||
|
verifyPasswordResetOtpRequest,
|
||||||
|
} 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 PasswordChecklist from "@/components/auth/PasswordChecklist";
|
||||||
|
import { useResendCooldown } from "@/hooks/useResendCooldown";
|
||||||
|
import { normaliseIdentifier } from "@/utils/identifier";
|
||||||
|
import { meetsAllRequirements } from "@/utils/passwordSchema";
|
||||||
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
|
type Stage = "identify" | "otp" | "password";
|
||||||
|
|
||||||
|
const ForgotPasswordPage = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
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.
|
||||||
|
const [ticket, setTicket] = useState<ResetTicket | null>(null);
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [verifying, setVerifying] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const resendCooldown = useResendCooldown();
|
||||||
|
|
||||||
|
/** The identifier as the API will see it — normalised once, reused everywhere. */
|
||||||
|
const normalised = normaliseIdentifier(identifier);
|
||||||
|
|
||||||
|
const sendCode = async () => {
|
||||||
|
await requestPasswordResetRequest({ identifier: normalised, channel });
|
||||||
|
setOtpCode("");
|
||||||
|
resendCooldown.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stage 1 — ask for a code. The API answers identically for unknown accounts,
|
||||||
|
// so we always advance; a non-existent identifier simply never receives a code.
|
||||||
|
const handleIdentify = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await sendCode();
|
||||||
|
setStage("otp");
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractApiError(err).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResend = async () => {
|
||||||
|
setError(null);
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await sendCode();
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractApiError(err).message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stage 2 — trade the code for a single-use ticket.
|
||||||
|
const handleVerify = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (otpCode.trim().length !== OTP_LENGTH) {
|
||||||
|
setError(`Enter the ${OTP_LENGTH}-digit code we sent you.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setVerifying(true);
|
||||||
|
try {
|
||||||
|
const result = await verifyPasswordResetOtpRequest({
|
||||||
|
identifier: normalised,
|
||||||
|
channel,
|
||||||
|
otp: otpCode.trim(),
|
||||||
|
});
|
||||||
|
setTicket(result);
|
||||||
|
setStage("password");
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractApiError(err).message);
|
||||||
|
} finally {
|
||||||
|
setVerifying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stage 3 — spend the ticket on IAM's set-password.
|
||||||
|
const handleReset = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
setError("Your reset session expired. Start again.");
|
||||||
|
setStage("identify");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVerifying(true);
|
||||||
|
try {
|
||||||
|
await resetPasswordRequest({
|
||||||
|
userId: ticket.userId,
|
||||||
|
// The API matches this against email / username / phone, so the typed
|
||||||
|
// identifier works regardless of which one it is.
|
||||||
|
email: normalised,
|
||||||
|
verificationCode: ticket.verificationCode,
|
||||||
|
newPassword: password,
|
||||||
|
confirmPassword,
|
||||||
|
});
|
||||||
|
navigate("/auth", {
|
||||||
|
replace: true,
|
||||||
|
state: { passwordReset: true },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(extractApiError(err).message);
|
||||||
|
} finally {
|
||||||
|
setVerifying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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."
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col">
|
||||||
|
{stage === "identify" ? (
|
||||||
|
<form onSubmit={handleIdentify} className="flex w-full flex-col">
|
||||||
|
<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">
|
||||||
|
<KeyRound size={22} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 mt-3 space-y-1.5 text-center sm:mb-5">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
|
Forgot your password?
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
Enter your email or phone number and we'll send you a code to
|
||||||
|
reset it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Email or Phone"
|
||||||
|
placeholder="name@company.com or 09XXXXXXXX"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
disabled={sending}
|
||||||
|
value={identifier}
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth
|
||||||
|
loading={sending}
|
||||||
|
disabled={!identifier.trim()}
|
||||||
|
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||||
|
>
|
||||||
|
Send code
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500">
|
||||||
|
Remembered it?{" "}
|
||||||
|
<Link to="/auth" className="font-semibold text-primary hover:underline">
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{stage === "otp" ? (
|
||||||
|
<OtpChannelStep
|
||||||
|
channel={channel}
|
||||||
|
target={normalised}
|
||||||
|
value={otpCode}
|
||||||
|
onChange={setOtpCode}
|
||||||
|
onVerify={handleVerify}
|
||||||
|
onBack={() => {
|
||||||
|
setStage("identify");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
onResend={handleResend}
|
||||||
|
resendIn={resendCooldown.secondsLeft}
|
||||||
|
sending={sending}
|
||||||
|
verifying={verifying}
|
||||||
|
error={error}
|
||||||
|
title="Enter your reset code"
|
||||||
|
description="Enter it to choose a new password."
|
||||||
|
submitLabel="Verify code"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{stage === "password" ? (
|
||||||
|
<form onSubmit={handleReset} className="flex w-full flex-col">
|
||||||
|
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
|
||||||
|
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||||
|
Choose a new password
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm leading-relaxed text-gray-500">
|
||||||
|
Pick something strong you haven't used before.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Stack gap="md">
|
||||||
|
<div>
|
||||||
|
<PasswordInput
|
||||||
|
label="New password"
|
||||||
|
placeholder="Create a strong password"
|
||||||
|
required
|
||||||
|
disabled={verifying}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
<PasswordChecklist value={password} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label="Confirm new password"
|
||||||
|
placeholder="Re-enter your password"
|
||||||
|
required
|
||||||
|
disabled={verifying}
|
||||||
|
error={
|
||||||
|
confirmPassword && confirmPassword !== password
|
||||||
|
? "Passwords do not match"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="edr-green"
|
||||||
|
fullWidth
|
||||||
|
loading={verifying}
|
||||||
|
disabled={
|
||||||
|
verifying ||
|
||||||
|
!meetsAllRequirements(password) ||
|
||||||
|
password !== confirmPassword
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<ArrowLeft size={14} />}
|
||||||
|
disabled={verifying}
|
||||||
|
onClick={() => {
|
||||||
|
setStage("otp");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForgotPasswordPage;
|
||||||
@@ -14,25 +14,13 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { AlertCircle, ArrowLeft } from "lucide-react";
|
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
import AuthShell from "@/components/auth/AuthShell";
|
import AuthShell from "@/components/auth/AuthShell";
|
||||||
|
import { normaliseIdentifier } from "@/utils/identifier";
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
|
|
||||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
|
||||||
const normaliseIdentifier = (raw: string): string => {
|
|
||||||
const v = raw.trim();
|
|
||||||
const digits = v.replace(/\D/g, "");
|
|
||||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
|
||||||
const local = digits.startsWith("251")
|
|
||||||
? digits.slice(3)
|
|
||||||
: digits.replace(/^0/, "");
|
|
||||||
return `+251${local}`;
|
|
||||||
}
|
|
||||||
return v.toLowerCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
const EDR_LOGO = "/assets/logo.svg";
|
const EDR_LOGO = "/assets/logo.svg";
|
||||||
|
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
@@ -111,14 +99,24 @@ const LoginPage = () => {
|
|||||||
onChange={(event) => setIdentifier(event.target.value)}
|
onChange={(event) => setIdentifier(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PasswordInput
|
<div>
|
||||||
label="Password"
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
placeholder="Enter your password"
|
<span className="text-sm font-medium">Password</span>
|
||||||
required
|
<Link
|
||||||
disabled={submitting}
|
to="/forgot-password"
|
||||||
value={password}
|
className="text-xs font-semibold text-primary hover:underline"
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
>
|
||||||
/>
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<PasswordInput
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
disabled={submitting}
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||||
|
|||||||
22
apps/edr-freight-web/backoffice/src/utils/identifier.ts
Normal file
22
apps/edr-freight-web/backoffice/src/utils/identifier.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||||
|
export function normaliseIdentifier(raw: string): string {
|
||||||
|
const v = raw.trim();
|
||||||
|
const digits = v.replace(/\D/g, "");
|
||||||
|
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||||
|
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||||
|
return `+251${local}`;
|
||||||
|
}
|
||||||
|
return v.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mask all but the first 7 chars of an E.164 phone for display. */
|
||||||
|
export const maskPhone = (p: string) =>
|
||||||
|
p.length > 4 ? `${p.slice(0, 7)}${"*".repeat(Math.max(0, p.length - 7))}` : p;
|
||||||
|
|
||||||
|
/** Mask the local part of an email for display (j***e@example.com). */
|
||||||
|
export const maskEmail = (email: string) => {
|
||||||
|
const [local, domain] = email.split("@");
|
||||||
|
if (!local || !domain) return email;
|
||||||
|
if (local.length <= 2) return `${local[0] ?? ""}***@${domain}`;
|
||||||
|
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
||||||
|
};
|
||||||
38
apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts
Normal file
38
apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Live checklist shown under the password field. Mirrors {@link passwordField}. */
|
||||||
|
export const passwordRequirements = [
|
||||||
|
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||||
|
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||||
|
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||||
|
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||||
|
{
|
||||||
|
label: "One special character",
|
||||||
|
test: (v: string) => /[^A-Za-z0-9]/.test(v),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Must stay in step with IAM's `@IsStrongPassword()` on `InitialResetPasswordDto`
|
||||||
|
* — a password this accepts but the API rejects surfaces as an opaque 400.
|
||||||
|
*/
|
||||||
|
export const passwordField = z
|
||||||
|
.string()
|
||||||
|
.min(8, "Password must be at least 8 characters")
|
||||||
|
.regex(/[A-Z]/, "Password must include an uppercase letter")
|
||||||
|
.regex(/[a-z]/, "Password must include a lowercase letter")
|
||||||
|
.regex(/\d/, "Password must include a number")
|
||||||
|
.regex(/[^A-Za-z0-9]/, "Password must include a special character");
|
||||||
|
|
||||||
|
export const confirmPasswordField = z
|
||||||
|
.string()
|
||||||
|
.min(1, "Please confirm your password");
|
||||||
|
|
||||||
|
export const samePassword = (data: {
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}) => data.password === data.confirmPassword;
|
||||||
|
|
||||||
|
/** Every requirement in {@link passwordRequirements} is satisfied. */
|
||||||
|
export const meetsAllRequirements = (value: string) =>
|
||||||
|
passwordRequirements.every((r) => r.test(value));
|
||||||
Reference in New Issue
Block a user