diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index efd6af448..c920e20d8 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -5,6 +5,7 @@ export const URL_CONSTANTS = { REFRESH_TOKEN: "/api/auth/refresh-token", LOGOUT: "/api/auth/logout", PROFILE: "/auth/profile", + CHANGE_PASSWORD: "/api/auth/change-password", FORGOT_PASSWORD_REQUEST: "/api/auth/forgot-password/request", FORGOT_PASSWORD_VERIFY: "/api/auth/forgot-password/verify", }, diff --git a/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx new file mode 100644 index 000000000..eebfd50c1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/ChangePasswordCard.tsx @@ -0,0 +1,165 @@ +import { useMutation } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { CheckCircle2, KeyRound, Save, XCircle } from "lucide-react"; +import { + Button, + Card, + Group, + PasswordInput, + Stack, + Text, + Title, +} from "@mantine/core"; +import { api } from "@/services/api"; + +/** + * Mirrors IAM's own `IsStrongPassword` rule on ChangePasswordDto — minLength 8, + * ≥1 lowercase, ≥1 number, ≥1 symbol, uppercase NOT required. Kept in step with + * the server so the user gets a precise message inline instead of a generic 400. + */ +const strongPassword = z + .string() + .min(8, "At least 8 characters") + .regex(/[a-z]/, "Include a lowercase letter") + .regex(/\d/, "Include a number") + .regex(/[^A-Za-z0-9]/, "Include a symbol"); + +const schema = z + .object({ + oldPassword: z.string().min(1, "Current password is required"), + newPassword: strongPassword, + confirmPassword: z.string().min(1, "Confirm your new password"), + }) + .refine((d) => d.newPassword === d.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match", + }) + .refine((d) => d.newPassword !== d.oldPassword, { + path: ["newPassword"], + message: "New password must be different from your current one", + }); + +type FormData = z.infer; + +/** IAM returns bare error codes; turn them into something a customer can act on. */ +const MESSAGES: Record = { + unable_to_change_password: "Your current password is incorrect.", + new_password_same_as_old: + "New password must be different from your current one.", + new_passwords_do_not_match: "The new passwords do not match.", + user_credentials_not_found: "This account has no password set.", +}; + +/** + * Password changes go straight to IAM's `PATCH /api/auth/change-password`, which + * verifies the old password and owns the credential write (argon hashing, + * retiring the previous credential). The freight app deliberately implements no + * part of that — it only collects the fields. + */ +export default function ChangePasswordCard() { + const { + register, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { oldPassword: "", newPassword: "", confirmPassword: "" }, + }); + + const mutation = useMutation({ + mutationFn: (data: FormData) => api.auth.changePassword.call(data), + // Never leave the old password sitting in component state after a change. + onSuccess: () => reset(), + }); + + const errorMessage = (err: unknown): string => { + const raw = ( + err as { response?: { data?: { message?: string | string[] } } } + )?.response?.data?.message; + const code = Array.isArray(raw) ? raw[0] : raw; + if (!code) return "Could not change your password. Please try again."; + return MESSAGES[code] ?? code; + }; + + return ( + + + + Password + + + Change the password you use to sign in. You'll need your current one. + + +
mutation.mutate(d))}> + + + + + + + + + {mutation.isSuccess && ( + + + + Password changed + + + )} + {mutation.isError && ( + + + + {errorMessage(mutation.error)} + + + )} + + + + + + +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx index 87916f38c..68d41727c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabAccount.tsx @@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { CheckCircle2, Save, ShieldCheck, UserCog, XCircle } from "lucide-react"; +import { + CheckCircle2, + Save, + ShieldCheck, + UserCog, + XCircle, +} from "lucide-react"; import { Alert, Button, @@ -23,6 +29,7 @@ import { toEthiopianE164, } from "@/components/PhoneField"; import type { AuthUser, ContactChannel } from "@/types/auth"; +import ChangePasswordCard from "./ChangePasswordCard"; const schema = z.object({ phoneNumber: z @@ -110,13 +117,16 @@ export default function TabAccount({ user }: TabAccountProps) { /** Name needs no proof of possession, so it saves straight through. */ const nameMutation = useMutation({ mutationFn: (data: FormData) => - api.account.updateName.call({ name: { en: data.nameEn, am: data.nameAm } }), + api.account.updateName.call({ + name: { en: data.nameEn, am: data.nameAm }, + }), onSuccess: refreshUser, }); /** Step 1 of a contact change: ask the server to code the new value. */ const otpMutation = useMutation({ - mutationFn: (change: PendingChange) => api.account.sendContactOtp.call(change), + mutationFn: (change: PendingChange) => + api.account.sendContactOtp.call(change), onSuccess: (res) => { setOtp(""); setSentTo(res.sentTo); @@ -183,14 +193,17 @@ export default function TabAccount({ user }: TabAccountProps) { }; const errorMessage = (err: unknown): string => { - const res = (err as { response?: { data?: { message?: string | string[] } } }) - ?.response?.data?.message; + const res = ( + err as { response?: { data?: { message?: string | string[] } } } + )?.response?.data?.message; if (Array.isArray(res)) return res[0]; return res ?? "Something went wrong. Please try again."; }; const busy = - otpMutation.isPending || contactMutation.isPending || nameMutation.isPending; + otpMutation.isPending || + contactMutation.isPending || + nameMutation.isPending; const savedSummary = completed.length && !queue.length @@ -200,175 +213,187 @@ export default function TabAccount({ user }: TabAccountProps) { : null; return ( - - - - Account - - - Your login details. Verification codes and SMS notifications are sent to - the phone number below. - - -
- - - - - - - - - - - - Changing your phone number or email requires a verification code sent - to the new one. + + + + + Account + + + Your login details. Verification codes and SMS notifications are sent + to the phone number below. - - - {savedSummary && ( - - - {savedSummary} - - )} - {nameMutation.isSuccess && !savedSummary && !queue.length && ( - - - Saved successfully - - )} - {(otpMutation.isError || nameMutation.isError) && ( - - - - {errorMessage(otpMutation.error ?? nameMutation.error)} - - - )} - - - - - - - - - - {current && ( +
- {totalSteps > 1 && ( - - Step {step} of {totalSteps} - - )} - - } color="blue"> - {sentTo ? ( - <> - We sent a 6-digit code to {sentTo}. Enter it to confirm - your new {CHANNEL_LABEL[current.channel]}. - - ) : ( - <>Sending a code to your new {CHANNEL_LABEL[current.channel]}… - )} - - - {completed.length > 0 && queue.length > 0 && ( - - - - {CHANNEL_LABEL[completed[completed.length - 1]]} updated — one - more to confirm. - - - )} - - - {contactMutation.isError && ( - - - {errorMessage(contactMutation.error)} - - )} + - + + + + + + + Changing your phone number or email requires a verification code + sent to the new one. + + + + + {savedSummary && ( + + + + {savedSummary} + + + )} + {nameMutation.isSuccess && !savedSummary && !queue.length && ( + + + + Saved successfully + + + )} + {(otpMutation.isError || nameMutation.isError) && ( + + + + {errorMessage(otpMutation.error ?? nameMutation.error)} + + + )} + + - - )} - - + +
+ + + {current && ( + + {totalSteps > 1 && ( + + Step {step} of {totalSteps} + + )} + + } color="blue"> + {sentTo ? ( + <> + We sent a 6-digit code to {sentTo}. Enter it to + confirm your new {CHANNEL_LABEL[current.channel]}. + + ) : ( + <> + Sending a code to your new {CHANNEL_LABEL[current.channel]}… + + )} + + + {completed.length > 0 && queue.length > 0 && ( + + + + {CHANNEL_LABEL[completed[completed.length - 1]]} updated — + one more to confirm. + + + )} + + + + {contactMutation.isError && ( + + + {errorMessage(contactMutation.error)} + + )} + + + + + + + )} + +
+ + +
); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index c5d632dbd..a984a49ae 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -68,6 +68,7 @@ import type { import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { AuthUser, + ChangePasswordPayload, CheckAvailabilityPayload, CheckAvailabilityResponse, GenerateVerificationCodePayload, @@ -149,6 +150,11 @@ export const api = { "verifyOTP", authService.verifyOTP, ), + changePassword: endpoint( + "auth", + "changePassword", + authService.changePassword, + ), logout: endpoint("auth", "logout", authService.logout), }, diff --git a/apps/edr-freight-web/portal/src/services/auth.service.ts b/apps/edr-freight-web/portal/src/services/auth.service.ts index 032ce0277..f72bbfaf9 100644 --- a/apps/edr-freight-web/portal/src/services/auth.service.ts +++ b/apps/edr-freight-web/portal/src/services/auth.service.ts @@ -1,6 +1,7 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import type { AuthUser, + ChangePasswordPayload, CheckAvailabilityPayload, CheckAvailabilityResponse, ForgotPasswordRequestPayload, @@ -111,6 +112,15 @@ export const authService = { return res.data.data; }, + /** + * Change the signed-in user's password via IAM's own route: it verifies the + * old password with argon and owns the credential write (deactivating the + * previous one), so this app never touches password material. + */ + changePassword: async (body: ChangePasswordPayload) => { + await client.patch(URL_CONSTANTS.AUTH.CHANGE_PASSWORD, body); + }, + // The three calls below manage the signed-in user's own account record // (`/api/me`), which is what OTPs and SMS notifications are delivered to. // Changing phone/email is OTP-gated server-side: the code goes to the NEW diff --git a/apps/edr-freight-web/portal/src/types/auth.ts b/apps/edr-freight-web/portal/src/types/auth.ts index 54df04f0a..0dadac608 100644 --- a/apps/edr-freight-web/portal/src/types/auth.ts +++ b/apps/edr-freight-web/portal/src/types/auth.ts @@ -45,6 +45,17 @@ export interface OtpResponse { message: string; } +/** + * Change the signed-in user's password. The old password is the proof of + * possession — IAM verifies it server-side and owns the credential write, so the + * freight app never hashes or stores a password itself. + */ +export interface ChangePasswordPayload { + oldPassword: string; + newPassword: string; + confirmPassword: string; +} + /** The contact channel being changed on the signed-in user's own account. */ export type ContactChannel = "email" | "phone";