Files
loyaly-merchant/src/features/settings/components/TeamManagement.tsx
2026-09-19 13:12:19 +05:30

274 lines
9.4 KiB
TypeScript

'use client';
import {useState} from 'react';
import {proportional} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Badge} from '@astryxdesign/core/Badge';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Avatar} from '@astryxdesign/core/Avatar';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {useInvitations, useTeam} from '@/features/team/hooks/useTeam';
import {teamRepository} from '@/features/team/repositories/teamRepository';
import {AddMemberDialog} from '@/features/team/components/AddMemberDialog';
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
import type {TeamMember} from '@/features/team/types/team';
/**
* Who has access to this console.
*
* ── What this replaced ───────────────────────────────────────────────────
* Five invented people — `aravind@nearle.in`, `Vikram Seth`, `Priya Sharma` —
* held in `useState`, with store names (`Indiranagar Flagship`,
* `Koramangala 80ft`) that no endpoint supplies, roles (`Cashier`,
* `Marketing`, `Support`) that do not exist upstream, and four controls that
* changed nothing: Add Staff, Suspend, Reset password and Remove all mutated
* local state and were lost on refresh. A merchant could not tell any of it
* from the real thing.
*
* ── Why the actions are here now ─────────────────────────────────────────
* This was read-only, because `PATCH /api/team/{id}`, the invitation endpoints
* and `POST /api/team/members` all answered 404 on the deployed platform when
* it was built. They do not any more, so the screen does what it always
* described: lists who has access and changes it.
*
* Deactivating revokes every session that person holds IMMEDIATELY — it is not
* a flag that takes effect at their next sign-in. The platform also refuses to
* remove the last active owner, answering `409 last_owner`; that message is
* surfaced verbatim rather than flattened into "something went wrong", because
* it names a rule the merchant can act on.
*/
interface TeamRow extends Record<string, unknown> {
id: string;
name: string;
email: string;
role: string;
status: string;
lastLogin: string;
}
/**
* Read off the payload, never inferred. The platform sends a boolean; there is
* no "invited" or "suspended" state on this endpoint, so the screen does not
* invent one.
*/
function toRow(m: TeamMember): TeamRow {
return {
id: m.id,
name: m.name,
email: m.email,
role: m.role,
status: m.active ? 'Active' : 'Inactive',
// "Never" is a fact worth showing: an account created and never used is
// exactly the row a manager wants to notice.
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
};
}
const COLUMNS: TableColumn<TeamRow>[] = [
{
key: 'name',
header: 'Member',
width: proportional(2),
renderCell: (row) => (
<HStack gap={2} vAlign="center">
<Avatar name={row.name} size="sm" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
<Text size="xsm" color="secondary">
{row.email}
</Text>
</VStack>
</HStack>
),
},
{
key: 'role',
header: 'Role',
width: proportional(1),
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
},
{
key: 'status',
header: 'Access',
width: proportional(1),
renderCell: (row) => (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={row.status === 'Active' ? 'success' : 'error'}
label={row.status}
/>
<Text size="sm" color="secondary">
{row.status}
</Text>
</HStack>
),
},
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'},
];
export function TeamManagement() {
const team = useTeam();
const invitations = useInvitations();
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reset, setReset] = useState<{label: string; value: string} | null>(
null,
);
function refresh() {
team.refetch();
invitations.refetch();
}
async function setActive(id: string, active: boolean) {
setError(null);
const res = await teamRepository.update(id, {active});
// `last_owner` is the one failure worth repeating word for word: it names a
// rule rather than a fault, and the merchant can act on it.
if (!res.ok) setError(res.message ?? 'Could not change that account.');
refresh();
}
async function resetPassword(id: string, email: string) {
setError(null);
const res = await teamRepository.resetPassword(id);
if (!res.ok || !res.data) {
setError(res.message ?? 'Could not reset that password.');
return;
}
setReset({label: `New password for ${email}`, value: res.data.password});
}
return (
<PanelCard
title="Console access"
subtitle="Everybody who can sign in to this company's dashboard."
resource={team}
loading={<SkeletonRows count={4} />}
empty={
<EmptyPanel
icon="staff"
title="No team members yet"
description="People appear here once they accept an invitation."
/>
}
>
{(members) => (
<VStack gap={4}>
<ResponsiveTable
data={members.map(toRow)}
idKey="id"
primaryKey="name"
summaryKeys={['role', 'status']}
columns={COLUMNS}
/>
{error ? <Banner status="error" title={error} /> : null}
{reset ? (
<SecretOnce value={reset.value} label={reset.label} />
) : null}
<HStack gap={2} hAlign="between" vAlign="center">
<Text size="xsm" color="secondary">
An invitation lets somebody choose their own password; a created
login shows one once. Deactivating signs that person out at once.
</Text>
<Button size="sm" onClick={() => setAdding(true)} label="Add someone" />
</HStack>
{members.some((m) => !m.active) ? (
<VStack gap={2}>
<Text size="xsm" color="secondary">
Deactivated
</Text>
{members
.filter((m) => !m.active)
.map((m) => (
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
<Text size="sm">{m.name}</Text>
<Button
size="sm"
variant="secondary"
onClick={() => void setActive(m.id, true)}
label="Restore access"
/>
</HStack>
))}
</VStack>
) : null}
<VStack gap={2}>
{members
.filter((m) => m.active)
.map((m) => (
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
<Text size="sm" color="secondary">
{m.email}
</Text>
<HStack gap={2}>
<Button
size="sm"
variant="secondary"
onClick={() => void resetPassword(m.id, m.email)}
label="Reset password"
/>
<Button
size="sm"
variant="secondary"
onClick={() => void setActive(m.id, false)}
label="Deactivate"
/>
</HStack>
</HStack>
))}
</VStack>
{invitations.data && invitations.data.length > 0 ? (
<VStack gap={2}>
<Text size="xsm" color="secondary">
Invited, not yet joined
</Text>
{invitations.data.map((inv) => (
<HStack key={inv.id} gap={2} vAlign="center" hAlign="between">
<Text size="sm" color="secondary">
{inv.email} · {inv.role}
</Text>
<Button
size="sm"
variant="secondary"
onClick={() => {
void teamRepository
.revokeInvitation(inv.id)
.then(() => invitations.refetch());
}}
label="Withdraw"
/>
</HStack>
))}
</VStack>
) : null}
{adding ? (
<AddMemberDialog
onClose={() => setAdding(false)}
onDone={() => {
setAdding(false);
refresh();
}}
/>
) : null}
</VStack>
)}
</PanelCard>
);
}