diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ce90554a6..813595690 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -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 ( } /> + } /> {/* } /> */} } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/auth/api.ts b/apps/edr-freight-web/backoffice/src/auth/api.ts index f72e72949..240c55001 100644 --- a/apps/edr-freight-web/backoffice/src/auth/api.ts +++ b/apps/edr-freight-web/backoffice/src/auth/api.ts @@ -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("/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( + "/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); +}; diff --git a/apps/edr-freight-web/backoffice/src/auth/types.ts b/apps/edr-freight-web/backoffice/src/auth/types.ts index 6279c8491..1c79819d5 100644 --- a/apps/edr-freight-web/backoffice/src/auth/types.ts +++ b/apps/edr-freight-web/backoffice/src/auth/types.ts @@ -58,6 +58,33 @@ export interface LoginResponse extends Partial { 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; diff --git a/apps/edr-freight-web/backoffice/src/components/auth/OtpChannelStep.tsx b/apps/edr-freight-web/backoffice/src/components/auth/OtpChannelStep.tsx new file mode 100644 index 000000000..b61bb2eab --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/OtpChannelStep.tsx @@ -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 ( +
+ + {label} + + onChange(v as OtpChannel)} + data={[ + { + value: "phone", + label: ( + + Phone + + ), + }, + { + value: "email", + label: ( + + Email + + ), + }, + ]} + /> +
+ ); +} + +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 ( + +
+ + + +
+ +
+

+ {title ?? `Verify your ${channel === "email" ? "email" : "phone"}`} +

+

+ We sent a {OTP_LENGTH}-digit code to{" "} + {maskedTarget}.{" "} + {description ?? "Enter it to continue."} +

+
+ + {error ? ( + }> + {error} + + ) : null} + + + + Verification code + + + + + + +
+ + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/auth/PasswordChecklist.tsx b/apps/edr-freight-web/backoffice/src/components/auth/PasswordChecklist.tsx new file mode 100644 index 000000000..89cea7a0e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/PasswordChecklist.tsx @@ -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 ( +
+ {passwordRequirements.map((req) => { + const met = req.test(value); + return ( +
+ + {met ? ( + + ) : ( + + )} + + + {req.label} + +
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/hooks/useResendCooldown.ts b/apps/edr-freight-web/backoffice/src/hooks/useResendCooldown.ts new file mode 100644 index 000000000..deeca8125 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useResendCooldown.ts @@ -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), + }; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/ForgotPasswordPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/ForgotPasswordPage.tsx new file mode 100644 index 000000000..7a21c0d8b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/auth/ForgotPasswordPage.tsx @@ -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("identify"); + const [identifier, setIdentifier] = useState(""); + const [channel, setChannel] = useState("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(null); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + + const [sending, setSending] = useState(false); + const [verifying, setVerifying] = useState(false); + const [error, setError] = useState(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) => { + 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) => { + 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 ( + +
+ {stage === "identify" ? ( +
+
+ + + +
+ +
+

+ Forgot your password? +

+

+ Enter your email or phone number and we'll send you a code to + reset it. +

+
+ + + setIdentifier(event.target.value)} + /> + + + +

+ The code goes to {identifierLabel}, which may differ from what you + typed above. +

+ + {error ? ( + }> + {error} + + ) : null} + + + +

+ Remembered it?{" "} + + Back to sign in + +

+
+
+ ) : null} + + {stage === "otp" ? ( + { + 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" ? ( +
+
+

+ Choose a new password +

+

+ Pick something strong you haven't used before. +

+
+ + +
+ setPassword(event.target.value)} + /> + +
+ + setConfirmPassword(event.target.value)} + /> + + {error ? ( + }> + {error} + + ) : null} + + + + +
+
+ ) : null} +
+
+ ); +}; + +export default ForgotPasswordPage; diff --git a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx index 849049bc2..811d7e34b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx @@ -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)} /> - setPassword(event.target.value)} - /> +
+
+ Password + + Forgot password? + +
+ setPassword(event.target.value)} + /> +
{error ? ( }> diff --git a/apps/edr-freight-web/backoffice/src/utils/identifier.ts b/apps/edr-freight-web/backoffice/src/utils/identifier.ts new file mode 100644 index 000000000..72677f73c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/utils/identifier.ts @@ -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}`; +}; diff --git a/apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts b/apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts new file mode 100644 index 000000000..207d9dc2b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/utils/passwordSchema.ts @@ -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));