feat(backoffice): add an Account tab with the customer's portal logins

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.
This commit is contained in:
Nathnael
2026-08-27 09:30:24 +00:00
parent f8e8897f5c
commit 3af3017b86
11 changed files with 431 additions and 1 deletions

View File

@@ -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<ExternalProfile>,
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
/**
* 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<CustomerAccount[]> {
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();
});
}
}

View File

@@ -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<CustomerAccount[]> {
return this.customerAccountsService.listForCompany(companyId);
}
@Get(":companyId/reset-target")
@BookingStaff(FREIGHT_PERMS.customers.resetPassword)

View File

@@ -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.

View File

@@ -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 (
<Group gap={10} wrap="nowrap" align="flex-start">
<Box c="edr-muted" mt={2}>
{icon}
</Box>
<Box style={{ minWidth: 0 }}>
<Text size="xs" c="edr-muted">
{label}
</Text>
<Group gap={6} wrap="wrap">
<Text size="sm" c="edr-text" style={{ wordBreak: "break-word" }}>
{value}
</Text>
{after}
</Group>
</Box>
</Group>
);
}
/**
* 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 (
<Card>
<Stack gap="sm">
<Group gap="sm" wrap="nowrap" align="flex-start">
<Avatar radius="xl" color="edr-green" variant="light">
{initials(account)}
</Avatar>
<Box style={{ minWidth: 0, flex: 1 }}>
<Group gap={6} wrap="wrap">
<Text fw={600} c="edr-text" style={{ wordBreak: "break-word" }}>
{name}
</Text>
{account.isPrimaryContact && (
<Badge size="xs" color="edr-green" variant="light">
Primary contact
</Badge>
)}
</Group>
{account.jobTitle && (
<Text size="xs" c="edr-muted">
{account.jobTitle}
</Text>
)}
</Box>
</Group>
<Group gap={6} wrap="wrap">
{orphaned ? (
<Badge
size="xs"
color="red"
variant="light"
leftSection={<ShieldAlert size={11} />}
>
No IAM account
</Badge>
) : (
<>
<Badge
size="xs"
color={account.isActive ? "edr-green" : "orange"}
variant="light"
>
{account.isActive ? "Active" : "Inactive"}
</Badge>
{account.status && (
<Badge size="xs" color="gray" variant="light">
{humanize(account.status)}
</Badge>
)}
{/* Created but never activated by its owner — usually the actual
answer to "they say they never got in". */}
{account.hasSetPassword === false && (
<Badge size="xs" color="yellow" variant="light">
Password never set
</Badge>
)}
</>
)}
{account.onboardingCompleted ? (
<Badge size="xs" color="edr-green" variant="light">
Onboarding submitted
</Badge>
) : (
<Badge size="xs" color="yellow" variant="light">
Onboarding
{account.onboardingStep
? ` · ${humanize(account.onboardingStep)}`
: " in progress"}
</Badge>
)}
</Group>
{!orphaned && (
<>
<Divider />
<Stack gap="xs">
<Field
icon={<UserRound size={14} />}
label="Username"
value={account.username}
/>
<Field
icon={<AtSign size={14} />}
label="Email"
value={account.email}
/>
<Field
icon={<Phone size={14} />}
label="Phone"
value={account.phoneNumber}
after={
account.phoneVerified === false ? (
<Badge size="xs" color="gray" variant="light">
Unverified
</Badge>
) : undefined
}
/>
</Stack>
</>
)}
<Text size="xs" c="edr-muted">
Created {formatDate(account.createdAt)}
</Text>
</Stack>
</Card>
);
}
export default AccountCard;

View File

@@ -15,6 +15,7 @@ export {
ChangeRequestReview,
ChangeRequestPendingBadge,
} from "./ChangeRequestReview";
export { AccountCard } from "./AccountCard";
export { CompanyTimeline } from "./CompanyTimeline";
export {
RequestDocumentChangeModal,

View File

@@ -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) =>

View File

@@ -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: {

View File

@@ -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() {
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
Invoices
</Tabs.Tab>
<Tabs.Tab value="accounts" leftSection={<KeyRound size={16} />}>
Account
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={16} />}>
History
</Tabs.Tab>
@@ -1488,6 +1499,51 @@ export default function CustomerDetailPage() {
</Tabs.Panel>
{/* 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. */}
<Tabs.Panel value="accounts" pt="lg">
{accountsQuery.isLoading ? (
<Center py="xl">
<Loader size="sm" color="edr-green" />
</Center>
) : accountsQuery.isError ? (
<Alert
color="red"
icon={<AlertTriangle size={16} />}
title="Failed to load accounts"
>
<Group justify="space-between" align="center">
<Text size="sm">
We couldn't load this customer's portal logins.
</Text>
<Button
size="xs"
variant="light"
onClick={() => void accountsQuery.refetch()}
>
Retry
</Button>
</Group>
</Alert>
) : (accountsQuery.data?.length ?? 0) === 0 ? (
<Card>
<Text size="sm" c="edr-muted" ta="center" py="md">
This customer has no portal login yet.
</Text>
</Card>
) : (
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
{accountsQuery.data?.map((account) => (
<AccountCard key={account.profileId} account={account} />
))}
</SimpleGrid>
)}
</Tabs.Panel>
<Tabs.Panel value="history" pt="lg">
<CompanyTimeline company={company} />
</Tabs.Panel>

View File

@@ -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",

View File

@@ -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<CustomerAccount[]> {
return apiClient
.get<CustomerAccount[]>(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

View File

@@ -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;