From 3af3017b861aeb6eedd5f1e7a39cc021c13303b6 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 27 Aug 2026 09:30:24 +0000 Subject: [PATCH] feat(backoffice): add an Account tab with the customer's portal logins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail page showed the company's business contact details but not the credentials anyone actually signs in with, and the two drift apart routinely — so "the customer says they can't log in" was unanswerable from this screen. Adds `GET /backoffice/customers/:companyId/accounts`, joining each external profile to its IAM account, primary contact first. Deliberately not filtered to active accounts: a suspended or never-activated login is exactly the case being looked into. The user query selects columns explicitly — the entity's relations include credentials and sessions, and this response reaches a browser. Rendered as cards rather than a table: it is a handful of rows of mostly-optional fields, which a table renders as a field of dashes. "Password never set" is called out on its own, being the usual answer to "they never got in", and a profile whose IAM user is gone reads as a red fault rather than an inactive status. --- .../modules/auth/customer-accounts.service.ts | 112 +++++++++++ .../modules/auth/customer-reset.controller.ts | 21 +- .../src/modules/auth/freight-auth.module.ts | 2 + .../src/components/customers/AccountCard.tsx | 188 ++++++++++++++++++ .../src/components/customers/index.ts | 1 + .../backoffice/src/constants/QUERY_KEYS.ts | 1 + .../backoffice/src/constants/URLS.ts | 2 + .../pages/customers/CustomerDetailPage.tsx | 56 ++++++ .../backoffice/src/services/api.ts | 8 + .../src/services/customers.service.ts | 13 ++ .../backoffice/src/types/customer.ts | 28 +++ 11 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx diff --git a/apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts b/apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts new file mode 100644 index 000000000..d051e268b --- /dev/null +++ b/apps/edr-freight-api/src/modules/auth/customer-accounts.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { In, Repository } from "typeorm"; + +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; + +import { ExternalProfile } from "../companies/entities/external-profile.entity"; + +/** + * One portal login belonging to a customer company: the company-side profile + * joined to the IAM account that actually signs in. + * + * The two halves drift apart routinely — `company.email` is business contact + * detail, while `email` here is the credential a reset link goes to — which is + * exactly why staff need to see the IAM side rather than the company row. + */ +export interface CustomerAccount { + /** external_profiles.id */ + profileId: string; + userId: string; + firstName: string; + lastName: string; + jobTitle: string | null; + isPrimaryContact: boolean; + onboardingStep: string | null; + onboardingCompleted: boolean; + /** Null when the profile points at a user row that no longer exists. */ + username: string | null; + email: string | null; + phoneNumber: string | null; + phoneVerified: boolean | null; + /** IAM account status (`EUserStatus`), surfaced as-is. */ + status: string | null; + isActive: boolean | null; + /** False means the account was created but never activated by its owner. */ + hasSetPassword: boolean | null; + createdAt: Date; +} + +@Injectable() +export class CustomerAccountsService { + constructor( + @InjectRepository(ExternalProfile) + private readonly profiles: Repository, + @InjectRepository(User) + private readonly users: Repository, + ) {} + + /** + * Every portal account for a company, primary contact first. + * + * Deliberately NOT filtered to active accounts: a suspended or never-activated + * login is the case staff are usually looking into, and hiding it would leave + * "the customer says they can't log in" unanswerable from this screen. + */ + async listForCompany(companyId: string): Promise { + const profiles = await this.profiles.find({ where: { companyId } }); + if (profiles.length === 0) return []; + + const userIds = profiles.map((p) => p.userId).filter(Boolean); + // Explicit select: the User entity's relations include credentials and + // sessions, and this response goes to a browser. + const users = userIds.length + ? await this.users + .createQueryBuilder("user") + .select([ + "user.id", + "user.username", + "user.email", + "user.phoneNumber", + "user.isPhoneNumberVerified", + "user.status", + "user.isActive", + "user.hasSetPassword", + ]) + .where({ id: In(userIds) }) + .getMany() + : []; + const byId = new Map(users.map((u) => [u.id, u])); + + return profiles + .map((p) => { + const user = byId.get(p.userId); + return { + profileId: p.id, + userId: p.userId, + firstName: p.firstName, + lastName: p.lastName, + jobTitle: p.jobTitle ?? null, + isPrimaryContact: p.isPrimaryContact, + onboardingStep: p.onboardingStep ?? null, + onboardingCompleted: p.onboardingCompleted ?? false, + username: user?.username ?? null, + email: user?.email ?? null, + phoneNumber: user?.phoneNumber ?? null, + phoneVerified: user?.isPhoneNumberVerified ?? null, + status: user?.status ?? null, + isActive: user?.isActive ?? null, + hasSetPassword: user?.hasSetPassword ?? null, + createdAt: p.createdAt, + }; + }) + .sort((a, b) => { + // Primary contact first — it is the account every staff action + // (password reset, notifications) actually targets. + if (a.isPrimaryContact !== b.isPrimaryContact) { + return a.isPrimaryContact ? -1 : 1; + } + return a.createdAt.getTime() - b.createdAt.getTime(); + }); + } +} diff --git a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts index 52a900fe8..8da218193 100644 --- a/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts +++ b/apps/edr-freight-api/src/modules/auth/customer-reset.controller.ts @@ -12,6 +12,10 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import { BookingStaff } from "../../common/booking-guards"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; import { BackofficeResetPasswordDto } from "./dto/forgot-password.dto"; +import { + CustomerAccount, + CustomerAccountsService, +} from "./customer-accounts.service"; import { CustomerResetService, CustomerResetTarget, @@ -25,7 +29,22 @@ import { @Controller("backoffice/customers") @ApiBearerAuth() export class CustomerResetController { - constructor(private readonly customerResetService: CustomerResetService) {} + constructor( + private readonly customerResetService: CustomerResetService, + private readonly customerAccountsService: CustomerAccountsService, + ) {} + + @Get(":companyId/accounts") + @BookingStaff(FREIGHT_PERMS.customers.view) + @ApiOperation({ + summary: + "The portal login accounts belonging to a customer, primary contact first", + }) + async accounts( + @Param("companyId", ParseUUIDPipe) companyId: string, + ): Promise { + return this.customerAccountsService.listForCompany(companyId); + } @Get(":companyId/reset-target") @BookingStaff(FREIGHT_PERMS.customers.resetPassword) diff --git a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts index 557e50fb3..9afe9efc0 100644 --- a/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts +++ b/apps/edr-freight-api/src/modules/auth/freight-auth.module.ts @@ -13,6 +13,7 @@ import { AccountController } from './account.controller'; import { AccountService } from './account.service'; import { CheckAvailabilityController } from './check-availability.controller'; import { CheckAvailabilityService } from './check-availability.service'; +import { CustomerAccountsService } from './customer-accounts.service'; import { CustomerResetController } from './customer-reset.controller'; import { CustomerResetService } from './customer-reset.service'; import { ForgotPasswordController } from './forgot-password.controller'; @@ -50,6 +51,7 @@ import { ListUsersService } from './list-users.service'; CheckAvailabilityService, ForgotPasswordService, CustomerResetService, + CustomerAccountsService, ], // Shipping-line registration mints activation links through the same // staff-triggered reset path customers use. diff --git a/apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx new file mode 100644 index 000000000..438f2293a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/AccountCard.tsx @@ -0,0 +1,188 @@ +import { + Avatar, + Badge, + Box, + Card, + Divider, + Group, + Stack, + Text, +} from "@mantine/core"; +import { AtSign, Phone, ShieldAlert, UserRound } from "lucide-react"; + +import type { CustomerAccount } from "@/types/customer"; +import { formatDate, humanize } from "./format"; + +/** First letters of the person's name; falls back to the login initial. */ +function initials(account: CustomerAccount): string { + const letters = [account.firstName, account.lastName] + .map((n) => n?.trim()?.[0]) + .filter(Boolean) + .join(""); + return (letters || account.username?.[0] || "?").toUpperCase(); +} + +/** A labelled value; rendered only when there is something to show. */ +function Field({ + icon, + label, + value, + after, +}: { + icon: React.ReactNode; + label: string; + value?: string | null; + after?: React.ReactNode; +}) { + if (!value?.trim()) return null; + return ( + + + {icon} + + + + {label} + + + + {value} + + {after} + + + + ); +} + +/** + * One portal login belonging to a customer. + * + * Distinct from the contact details on the Overview tab: those are the business + * contact info on the company row, this is the credential someone actually + * signs in with — the two drift apart routinely, and staff answering "the + * customer can't log in" need this one. + */ +export function AccountCard({ account }: { account: CustomerAccount }) { + const name = + `${account.firstName ?? ""} ${account.lastName ?? ""}`.trim() || + account.username || + "Unnamed account"; + + // No IAM row at all — the profile points at a user that is gone. Treated as a + // fault rather than a status: nothing below it can be trusted, so the card + // says so once, loudly, instead of drawing empty credential fields. + const orphaned = account.username === null; + + return ( + + + + + {initials(account)} + + + + + {name} + + {account.isPrimaryContact && ( + + Primary contact + + )} + + {account.jobTitle && ( + + {account.jobTitle} + + )} + + + + + {orphaned ? ( + } + > + No IAM account + + ) : ( + <> + + {account.isActive ? "Active" : "Inactive"} + + {account.status && ( + + {humanize(account.status)} + + )} + {/* Created but never activated by its owner — usually the actual + answer to "they say they never got in". */} + {account.hasSetPassword === false && ( + + Password never set + + )} + + )} + {account.onboardingCompleted ? ( + + Onboarding submitted + + ) : ( + + Onboarding + {account.onboardingStep + ? ` · ${humanize(account.onboardingStep)}` + : " in progress"} + + )} + + + {!orphaned && ( + <> + + + } + label="Username" + value={account.username} + /> + } + label="Email" + value={account.email} + /> + } + label="Phone" + value={account.phoneNumber} + after={ + account.phoneVerified === false ? ( + + Unverified + + ) : undefined + } + /> + + + )} + + + Created {formatDate(account.createdAt)} + + + + ); +} + +export default AccountCard; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts index 864a89ae6..463a85eca 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -15,6 +15,7 @@ export { ChangeRequestReview, ChangeRequestPendingBadge, } from "./ChangeRequestReview"; +export { AccountCard } from "./AccountCard"; export { CompanyTimeline } from "./CompanyTimeline"; export { RequestDocumentChangeModal, diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 3f1cce1b5..66a55b555 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -80,6 +80,7 @@ export const QUERY_KEYS = { documents: (id: string) => ["customers", "detail", id, "documents"] as const, payments: (id: string) => ["customers", "detail", id, "payments"] as const, + accounts: (id: string) => ["customers", "detail", id, "accounts"] as const, resetTarget: (id: string) => ["customers", "detail", id, "reset-target"] as const, changeRequests: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 8249537a5..ad0fbafc9 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -138,6 +138,8 @@ export const URL_CONSTANTS = { `/backoffice/customers/${companyId}/reset-password`, RESET_TARGET: (companyId: string) => `/backoffice/customers/${companyId}/reset-target`, + ACCOUNTS: (companyId: string) => + `/backoffice/customers/${companyId}/accounts`, }, BILLING: { diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index ec89279a2..8f315dff0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -29,6 +29,7 @@ import { FileText, History, Hourglass, + KeyRound, IdCard, LayoutGrid, Package, @@ -43,6 +44,7 @@ import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { + AccountCard, BookingStatusBadge, ChangeRequestPendingBadge, ChangeRequestReview, @@ -157,6 +159,12 @@ export default function CustomerDetailPage() { enabled: Boolean(id), }), ); + const accountsQuery = useQuery( + api.customers.accounts.queryOptions({ + input: { companyId: id ?? "" }, + enabled: Boolean(id), + }), + ); const documentsQuery = useQuery( api.customers.documents.queryOptions({ input: { id: id ?? "" }, @@ -823,6 +831,9 @@ export default function CustomerDetailPage() { }> Invoices + }> + Account + }> History @@ -1488,6 +1499,51 @@ export default function CustomerDetailPage() { {/* HISTORY */} + {/* ACCOUNT — the IAM logins behind this customer. Distinct from the + contact details on Overview: those are business contact info on the + company row, these are the credentials someone actually signs in + with, and the two drift apart routinely. Cards rather than a table: + it is a handful of rows of mostly-optional detail, which a table + renders as a field of dashes. */} + + {accountsQuery.isLoading ? ( +
+ +
+ ) : accountsQuery.isError ? ( + } + title="Failed to load accounts" + > + + + We couldn't load this customer's portal logins. + + + + + ) : (accountsQuery.data?.length ?? 0) === 0 ? ( + + + This customer has no portal login yet. + + + ) : ( + + {accountsQuery.data?.map((account) => ( + + ))} + + )} +
+ diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index efb6eed47..9f8b4b877 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -13,6 +13,7 @@ import type { CustomerBooking, CustomerDocument, CustomerPayment, + CustomerAccount, CustomerResetTarget, PaginatedCompanies, ProfileStatus, @@ -3222,6 +3223,13 @@ export const api = { ({ id }) => QUERY_KEYS.CUSTOMERS.payments(id), ), + accounts: endpoint<{ companyId: string }, CustomerAccount[]>( + "customers", + "accounts", + ({ companyId }) => customersService.accounts(companyId), + ({ companyId }) => QUERY_KEYS.CUSTOMERS.accounts(companyId), + ), + resetTarget: endpoint<{ companyId: string }, CustomerResetTarget>( "customers", "resetTarget", diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts index 5b3cc4527..6adf413e3 100644 --- a/apps/edr-freight-web/backoffice/src/services/customers.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -10,6 +10,7 @@ import type { CustomerBooking, CustomerDocument, CustomerPayment, + CustomerAccount, CustomerResetTarget, PaginatedCompanies, ProfileStatus, @@ -90,6 +91,18 @@ export const customersService = { .then((r) => r.data); }, + /** + * Every portal login belonging to this customer, primary contact first. + * + * Not filtered to active accounts — a suspended or never-activated login is + * exactly what staff are checking when a customer says they cannot sign in. + */ + accounts(companyId: string): Promise { + return apiClient + .get(URL_CONSTANTS.COMPANIES.ACCOUNTS(companyId)) + .then((r) => r.data); + }, + /** * The IAM account a reset link would go to. Read before offering the action * so staff see the credentials the link actually reaches, not the company's diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts index db5da275e..1d3eb7588 100644 --- a/apps/edr-freight-web/backoffice/src/types/customer.ts +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -177,6 +177,34 @@ export interface ResetPasswordResult { * Distinct from `Company.email` / `Company.phone`, which are business contact * details and routinely differ from the credentials the customer logs in with. */ +/** + * One portal login belonging to a customer: the company-side profile joined to + * the IAM account that actually signs in. Mirrors the API's `CustomerAccount`. + * + * The IAM fields are null when the profile points at a user row that no longer + * exists — surfaced rather than hidden, since that is itself a fault worth + * seeing. + */ +export interface CustomerAccount { + profileId: string; + userId: string; + firstName: string; + lastName: string; + jobTitle: string | null; + isPrimaryContact: boolean; + onboardingStep: string | null; + onboardingCompleted: boolean; + username: string | null; + email: string | null; + phoneNumber: string | null; + phoneVerified: boolean | null; + status: string | null; + isActive: boolean | null; + /** False means the account exists but its owner never set a password. */ + hasSetPassword: boolean | null; + createdAt: string; +} + export interface CustomerResetTarget { userId: string; name: string;