mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
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:
@@ -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;
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
ChangeRequestReview,
|
||||
ChangeRequestPendingBadge,
|
||||
} from "./ChangeRequestReview";
|
||||
export { AccountCard } from "./AccountCard";
|
||||
export { CompanyTimeline } from "./CompanyTimeline";
|
||||
export {
|
||||
RequestDocumentChangeModal,
|
||||
|
||||
Reference in New Issue
Block a user