mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
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.
189 lines
5.4 KiB
TypeScript
189 lines
5.4 KiB
TypeScript
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;
|