feat: setup forget password to the backoffice

This commit is contained in:
Nathnael
2026-07-20 06:19:12 +00:00
committed by Hagernesh
parent 149548a9b1
commit ccd9afacb1
10 changed files with 708 additions and 23 deletions

View File

@@ -46,6 +46,7 @@ import {
import { useAuth } from "./auth/useAuth";
import LoadingScreen from "./components/LoadingScreen";
import LoginPage from "./pages/auth/LoginPage";
import ForgotPasswordPage from "./pages/auth/ForgotPasswordPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
@@ -696,6 +697,7 @@ const App = () => {
return (
<Routes>
<Route path="/auth" element={<LoginPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
<Route path="um/set-password" element={<SetPassword />} />
<Route path="/callback" element={<FaydaCallbackPage />} />

View File

@@ -1,5 +1,13 @@
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: {
email: string;
@@ -21,3 +29,31 @@ export const getMeRequest = async () => {
const response = await api.get<AuthUser>("/me");
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);
};

View File

@@ -58,6 +58,33 @@ 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 {
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
export interface User {
id: string;

View File

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

View File

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

View File

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

View File

@@ -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&apos;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&apos;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;

View File

@@ -14,25 +14,13 @@ import {
Title,
} from "@mantine/core";
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 AuthShell from "@/components/auth/AuthShell";
import { normaliseIdentifier } from "@/utils/identifier";
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 LoginPage = () => {
@@ -111,14 +99,24 @@ const LoginPage = () => {
onChange={(event) => setIdentifier(event.target.value)}
/>
<PasswordInput
label="Password"
placeholder="Enter your password"
required
disabled={submitting}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-sm font-medium">Password</span>
<Link
to="/forgot-password"
className="text-xs font-semibold text-primary hover:underline"
>
Forgot password?
</Link>
</div>
<PasswordInput
placeholder="Enter your password"
required
disabled={submitting}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
{error ? (
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>

View 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}`;
};

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