diff --git a/apps/edr-freight-api/src/common/request-log-context.spec.ts b/apps/edr-freight-api/src/common/request-log-context.spec.ts index 403b1a137..e898cb930 100644 --- a/apps/edr-freight-api/src/common/request-log-context.spec.ts +++ b/apps/edr-freight-api/src/common/request-log-context.spec.ts @@ -65,10 +65,40 @@ describe("RequestLogMiddleware", () => { originalUrl: "/api/bookings/1/submit?dry=1", baseUrl: "/api/bookings", route: { path: "/:id/submit" }, - headers: { "user-agent": "jest", "x-request-id": "req-42" }, + headers: { + "user-agent": "jest", + "x-request-id": "req-42", + authorization: "Bearer tok", + "x-client-app": "freight-backoffice", + "current-project-id": "proj-3", + }, ip: "10.0.0.1", query: { dry: "1" }, - user: { id: "u-7" }, + user: { + id: "u-7", + sessionId: "sess-9", + userType: "STAFF", + status: "ACTIVE", + username: "nati", + email: "nati@example.com", + phoneNumber: "0911000000", + name: { en: "Nati" }, + roles: [{ key: "freight_operations" }], + permissions: [{ key: "a" }, { key: "b" }], + employee: { + id: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + position: { + id: "pos-5", + key: "ops_officer", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + positionType: { key: "operations" }, + }, + }, + }, }; const res = { statusCode: 409, @@ -105,6 +135,29 @@ describe("RequestLogMiddleware", () => { bookingId: "b-1", booking: { outcome: "REJECTED" }, }); + expect(JSON.parse(lines[0]).auth).toEqual({ + authenticated: true, + hasBearer: true, + clientApp: "freight-backoffice", + userId: "u-7", + sessionId: "sess-9", + userType: "STAFF", + userStatus: "ACTIVE", + roles: ["freight_operations"], + permissionCount: 2, + employeeId: "emp-1", + organizationId: "org-1", + unitId: "unit-2", + positionId: "pos-5", + positionKey: "ops_officer", + positionType: "operations", + employeePositionId: "ep-6", + isDelegate: true, + delegatorId: "pos-1", + projectId: "proj-3", + }); + // No personal data reaches the line, whatever the token carried. + expect(lines[0]).not.toMatch(/nati|example\.com|0911000000/); expect(res.setHeader).toHaveBeenCalledWith("x-request-id", "req-42"); jest.restoreAllMocks(); }); 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."} + + + + setOpen(true)}> + Change 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" ? ( + + New email + setNewEmail(e.target.value)} + /> + {formError && {formError}} + + ) : ( + + Verification code + setOtp(e.target.value)} + /> + {formError && {formError}} + + )} + + + + Cancel + + {step === "enterEmail" ? ( + + {sendOtpMutation.isPending ? ( + + ) : ( + "Send code" + )} + + ) : ( + + {updateContactMutation.isPending ? ( + + ) : ( + "Confirm" + )} + + )} + + + + + ); +} 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. + + + setOpen(true)}> + Change password + + + + !next && closeDialog()}> + + + Change password + + You'll be signed out and asked to log in again once it's changed. + + + + + Current password + setOldPassword(e.target.value)} + /> + + + New password + setNewPassword(e.target.value)} + /> + + + Confirm new password + setConfirmPassword(e.target.value)} + /> + + {formError && {formError}} + + + + Cancel + + + {changePasswordMutation.isPending ? ( + + ) : ( + "Change password" + )} + + + + + + ); +} 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", diff --git a/packages/api-common/src/logging/request-log.middleware.ts b/packages/api-common/src/logging/request-log.middleware.ts index 5d9ead8b0..4144e72c8 100644 --- a/packages/api-common/src/logging/request-log.middleware.ts +++ b/packages/api-common/src/logging/request-log.middleware.ts @@ -20,7 +20,39 @@ interface LoggedRequest { headers: Record; ip?: string; query?: Record; - user?: Record | null; + /** Set by the IAM JwtGuard AFTER this middleware runs — read at emit time. */ + user?: AuthenticatedUser | null; + currentUnitId?: string; +} + +/** + * The subset of `TCurrentUser` (@tria-plc/api-common) the log line reads. + * Everything here is an identifier, a key or a status — the personal fields on + * that type (name, email, username, phoneNumber) are deliberately absent so + * they cannot be picked up by accident. + */ +interface AuthenticatedUser { + id?: string; + sub?: string; + userId?: string; + sessionId?: string; + userType?: string; + status?: string; + roles?: { key?: string }[]; + permissions?: unknown[]; + employee?: { + id?: string; + organizationId?: string; + unitId?: string; + position?: { + id?: string; + key?: string; + employeePositionId?: string; + isDelegate?: boolean; + delegatorId?: string; + positionType?: { key?: string }; + }; + }; } interface LoggedResponse { @@ -35,13 +67,48 @@ const header = (req: LoggedRequest, name: string): string | undefined => { return Array.isArray(value) ? value[0] : value; }; -const userId = (req: LoggedRequest): string | undefined => { +const userId = (req: LoggedRequest): string | undefined => + req.user?.id ?? req.user?.sub ?? req.user?.userId; + +/** + * Who the caller was acting as — WITHOUT any personal data. Ids, role/position + * keys and statuses only: enough to answer "which desk did this", "was it a + * delegate", "which tenant", and to spot an authorization problem, with nothing + * that identifies the human behind the account beyond the opaque user id. + * + * `authenticated: false` with `hasBearer: true` is the signature of a rejected + * token (expired session, bad signature) as opposed to a missing one. + */ +const authContext = (req: LoggedRequest): Record => { const user = req.user; - if (!user) return undefined; - const id = user.id ?? user.sub ?? user.userId; - return typeof id === "string" || typeof id === "number" - ? String(id) - : undefined; + const position = user?.employee?.position; + return { + authenticated: Boolean(user), + hasBearer: header(req, "authorization")?.startsWith("Bearer ") ?? false, + // Which frontend called — /auth/login rejects cross-audience credentials on it. + clientApp: header(req, "x-client-app"), + userId: userId(req), + sessionId: user?.sessionId, + userType: user?.userType, + userStatus: user?.status, + roles: user?.roles?.map((role) => role.key).filter(Boolean), + // Count only: the full grant list is hundreds of keys and would dwarf the line. + permissionCount: user?.permissions?.length, + employeeId: user?.employee?.id, + organizationId: user?.employee?.organizationId, + unitId: user?.employee?.unitId ?? req.currentUnitId, + positionId: position?.id, + positionKey: position?.key, + positionType: position?.positionType?.key, + employeePositionId: position?.employeePositionId, + // Acting on someone else's behalf — the first thing to check when a staff + // action lands under an unexpected desk. + isDelegate: position?.isDelegate, + delegatorId: position?.delegatorId, + // Tenant/scope headers the frontends send alongside the token. + projectId: + header(req, "current-project-id") ?? header(req, "x-current-project-id"), + }; }; /** @@ -101,6 +168,9 @@ export class RequestLogMiddleware implements NestMiddleware { status, durationMs, userId: userId(req), + // Read at emit time on purpose: the guard populates req.user long + // after this middleware handed control on. + auth: authContext(req), ip: req.ip, userAgent: header(req, "user-agent"), query:
{formError}