diff --git a/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx new file mode 100644 index 000000000..650616049 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/ChangeEmailCard.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { Mail, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in backoffice user change their own email. Goes through + * /me/contact/otp + /me/contact rather than the generic (unverified) + * /auth/update-profile route, so the new address is proven before it's + * written — see account.controller.ts on the API side. + */ +export function ChangeEmailCard() { + const { user } = useAuth(); + const sendOtpMutation = useMutation( + api.account.sendContactOtp.mutationOptions(), + ); + const updateContactMutation = useMutation( + api.account.updateContact.mutationOptions(), + ); + + const [open, setOpen] = useState(false); + const [step, setStep] = useState<"enterEmail" | "enterOtp">("enterEmail"); + const [newEmail, setNewEmail] = useState(""); + const [otp, setOtp] = useState(""); + const [formError, setFormError] = useState(""); + + const closeDialog = () => { + setOpen(false); + setStep("enterEmail"); + setNewEmail(""); + setOtp(""); + setFormError(""); + }; + + const sendOtp = () => { + setFormError(""); + if (!newEmail.trim()) { + setFormError("Enter the new email address."); + return; + } + + sendOtpMutation.mutate( + { channel: "email", value: newEmail.trim() }, + { + onSuccess: (result) => { + toast.success(`Verification code sent to ${result.sentTo}`); + setStep("enterOtp"); + }, + }, + ); + }; + + const confirmOtp = () => { + setFormError(""); + if (!otp.trim()) { + setFormError("Enter the verification code."); + return; + } + + updateContactMutation.mutate( + { channel: "email", value: newEmail.trim(), otp: otp.trim() }, + { + onSuccess: () => { + toast.success("Email updated."); + closeDialog(); + // Refetches the session so the new email shows everywhere — simplest + // way to refresh the cached user without a dedicated context method. + window.location.reload(); + }, + }, + ); + }; + + return ( + + + + + Email + + + {user?.email ? `Current email: ${user.email}` : "Change your account email."} + + + + + + + !next && closeDialog()}> + + + Change email + + {step === "enterEmail" + ? "We'll send a verification code to the new address." + : `Enter the code sent to ${newEmail}.`} + + + + {step === "enterEmail" ? ( +
+ + setNewEmail(e.target.value)} + /> + {formError &&

{formError}

} +
+ ) : ( +
+ + setOtp(e.target.value)} + /> + {formError &&

{formError}

} +
+ )} + + + + {step === "enterEmail" ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx new file mode 100644 index 000000000..fb5f9086e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/profile/ChangePasswordCard.tsx @@ -0,0 +1,154 @@ +import { useState } from "react"; +import { KeyRound, Loader2 } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { api } from "@/services/api"; +import { useAuth } from "@/auth/useAuth"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in backoffice user change their own password. The account + * is logged out on success — the old token was issued under the old + * password, and this forces a clean re-login rather than trusting the + * server to keep the existing session valid. + */ +export function ChangePasswordCard() { + const { logout } = useAuth(); + const changePasswordMutation = useMutation( + api.account.changePassword.mutationOptions(), + ); + + const [open, setOpen] = useState(false); + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [formError, setFormError] = useState(""); + + const closeDialog = () => { + setOpen(false); + setOldPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setFormError(""); + }; + + const submit = () => { + setFormError(""); + + if (!oldPassword || !newPassword || !confirmPassword) { + setFormError("All fields are required."); + return; + } + if (newPassword.length < 8) { + setFormError("New password must be at least 8 characters."); + return; + } + if (newPassword === oldPassword) { + setFormError("New password must be different from the current one."); + return; + } + if (newPassword !== confirmPassword) { + setFormError("New password and confirmation do not match."); + return; + } + + changePasswordMutation.mutate( + { oldPassword, newPassword, confirmPassword }, + { + onSuccess: () => { + toast.success("Password changed. Please sign in again."); + closeDialog(); + setTimeout(logout, 1200); + }, + }, + ); + }; + + return ( + + + + + Password + + Change the password for your account. + + + + + + !next && closeDialog()}> + + + Change password + + You'll be signed out and asked to log in again once it's changed. + + +
+
+ + setOldPassword(e.target.value)} + /> +
+
+ + setNewPassword(e.target.value)} + /> +
+
+ + setConfirmPassword(e.target.value)} + /> +
+ {formError &&

{formError}

} +
+ + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx index 708a5a3ca..c7bd1b197 100644 --- a/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx +++ b/apps/edr-freight-web/backoffice/src/layout/TopBar.tsx @@ -319,7 +319,7 @@ export const TopBar = () => { {t("header.viewProfile")} navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} className="cursor-pointer hover:bg-primary-50 dark:hover:bg-primary-900/30 hover:text-primary-700 dark:hover:text-primary-400 py-2.5"> {t("header.changePassword")} diff --git a/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx b/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx index ed569a83d..46ddcfe7d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/HomePage/Header.tsx @@ -502,7 +502,7 @@ const Header = () => { navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} > diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx index baf548d92..37f91909e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/MyProfilePage.tsx @@ -1,8 +1,12 @@ import { MySignatureCard } from "@/components/profile/MySignatureCard"; +import { ChangePasswordCard } from "@/components/profile/ChangePasswordCard"; +import { ChangeEmailCard } from "@/components/profile/ChangeEmailCard"; export default function MyProfilePage() { return (
+ +
diff --git a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx index ff7678856..4f58ae6b7 100644 --- a/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx +++ b/apps/edr-freight-web/backoffice/src/record-management/components/common/Top.tsx @@ -551,7 +551,7 @@ const Top: React.FC = ({ navigate("/change-password")} + onClick={() => navigate("/dashboard/profile")} > {t("header.changePassword")} diff --git a/apps/edr-freight-web/backoffice/src/services/account.service.ts b/apps/edr-freight-web/backoffice/src/services/account.service.ts new file mode 100644 index 000000000..40511bf79 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/account.service.ts @@ -0,0 +1,50 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; + +export type ContactChannel = "email" | "phone"; + +export interface ChangePasswordPayload { + oldPassword: string; + newPassword: string; + confirmPassword: string; +} + +export interface SendContactOtpPayload { + channel: ContactChannel; + /** The NEW email/phone to verify — the OTP is sent here, not to the current one. */ + value: string; +} + +export interface UpdateContactPayload extends SendContactOtpPayload { + otp: string; +} + +export const accountService = { + /** PATCH /auth/change-password — generic IAM route, works for any user type. */ + changePassword: async (payload: ChangePasswordPayload): Promise => { + const response = await client.patch("/auth/change-password", payload); + unwrap(response.data); + }, + + /** POST /me/contact/otp — sends a code to the new email/phone to prove ownership. */ + sendContactOtp: async ( + payload: SendContactOtpPayload, + ): Promise<{ sentTo: string }> => { + const response = await client.post<{ sentTo: string }>( + "/me/contact/otp", + payload, + ); + return unwrap(response.data); + }, + + /** PATCH /me/contact — verifies the OTP and writes the new email/phone. */ + updateContact: async ( + payload: UpdateContactPayload, + ): Promise<{ success: true; value: string }> => { + const response = await client.patch<{ success: true; value: string }>( + "/me/contact", + payload, + ); + return unwrap(response.data); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 6c9c69788..2a1963f8d 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -136,6 +136,12 @@ import type { WarehouseZone, } from "@/types/warehouse"; import { endpoint } from "@/utils/endpoint"; +import { + accountService, + type ChangePasswordPayload, + type SendContactOtpPayload, + type UpdateContactPayload, +} from "./account.service"; import { BookingListFilter, bookingsService, @@ -2182,6 +2188,29 @@ export const api = { ), }, + account: { + changePassword: endpoint( + "me", + "change-password", + (payload) => accountService.changePassword(payload), + ), + + sendContactOtp: endpoint( + "me", + "send-contact-otp", + (payload) => accountService.sendContactOtp(payload), + ), + + updateContact: endpoint< + UpdateContactPayload, + { success: true; value: string } + >( + "me", + "update-contact", + (payload) => accountService.updateContact(payload), + ), + }, + signatures: { mySignature: endpoint( "me",