274 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|