first commit
This commit is contained in:
183
src/features/team/components/AddMemberDialog.tsx
Normal file
183
src/features/team/components/AddMemberDialog.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import {teamRepository} from '@/features/team/repositories/teamRepository';
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Add somebody to the console, two ways.
|
||||
*
|
||||
* ── Invitation first, deliberately ───────────────────────────────────────
|
||||
* An invitation is the default because the person redeems it and chooses their
|
||||
* own password, so the merchant never handles a credential belonging to
|
||||
* somebody else. A password an operator invents for a colleague is weak, gets
|
||||
* sent over WhatsApp, and is never changed.
|
||||
*
|
||||
* Direct creation exists because the invitation flow needs the recipient's
|
||||
* device to redeem on, and a shop hiring somebody at the counter does not
|
||||
* always have that. It hands back a generated password, shown once.
|
||||
*
|
||||
* `owner` is absent from the role list on purpose: promoting somebody to owner
|
||||
* is a different decision from adding a person, the platform guards it
|
||||
* separately, and doing it by accident on a form like this is hard to undo.
|
||||
*/
|
||||
const ROLES: {value: UserRole; label: string}[] = [
|
||||
{value: 'staff', label: 'Staff'},
|
||||
{value: 'manager', label: 'Manager'},
|
||||
];
|
||||
|
||||
export function AddMemberDialog({
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<'invite' | 'create'>('invite');
|
||||
const [email, setEmail] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [role, setRole] = useState<UserRole>('staff');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [secret, setSecret] = useState<{label: string; value: string} | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const canSubmit = email.trim() !== '';
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const body = {email: email.trim(), full_name: name.trim(), role};
|
||||
|
||||
if (mode === 'invite') {
|
||||
const res = await teamRepository.invite(body);
|
||||
setBusy(false);
|
||||
if (!res.ok || !res.data) {
|
||||
setError(res.message ?? 'Could not send that invitation.');
|
||||
return;
|
||||
}
|
||||
// No code on the response would mean the platform changed its contract;
|
||||
// say so rather than rendering an empty box that looks like a bug.
|
||||
setSecret(
|
||||
res.data.code
|
||||
? {label: `Invitation code for ${res.data.email}`, value: res.data.code}
|
||||
: null,
|
||||
);
|
||||
if (!res.data.code) onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await teamRepository.create(body);
|
||||
setBusy(false);
|
||||
if (!res.ok || !res.data) {
|
||||
setError(res.message ?? 'Could not create that login.');
|
||||
return;
|
||||
}
|
||||
setSecret({
|
||||
label: `Password for ${res.data.email}`,
|
||||
value: res.data.password,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={460}
|
||||
aria-label="Add team member"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Add someone"
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{secret ? (
|
||||
<SecretOnce
|
||||
value={secret.value}
|
||||
label={secret.label}
|
||||
note={
|
||||
mode === 'invite'
|
||||
? 'Give this to them — they open /join on this console, enter it and choose a password. It works once and cannot be shown again.'
|
||||
: 'Give this to them. It cannot be shown again.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SegmentedControl
|
||||
label="How to add them"
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v === 'create' ? 'create' : 'invite')}
|
||||
layout="fill"
|
||||
>
|
||||
<SegmentedControlItem value="invite" label="Send an invitation" />
|
||||
<SegmentedControlItem value="create" label="Create a login" />
|
||||
</SegmentedControl>
|
||||
|
||||
<Text size="xsm" color="secondary">
|
||||
{mode === 'invite'
|
||||
? 'They redeem the code and choose their own password. You never see it.'
|
||||
: 'A password is generated and shown once. Use this when they cannot redeem a code.'}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="name@company.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
isOptional
|
||||
/>
|
||||
<Selector
|
||||
label="Role"
|
||||
options={ROLES.map((r) => ({value: r.value, label: r.label}))}
|
||||
value={role}
|
||||
onChange={(v) => setRole(v as UserRole)}
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
</>
|
||||
)}
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={secret ? onDone : onClose}
|
||||
label={secret ? 'Done' : 'Cancel'}
|
||||
/>
|
||||
{secret ? null : (
|
||||
<Button
|
||||
isDisabled={!canSubmit || busy}
|
||||
onClick={() => void submit()}
|
||||
label={
|
||||
busy
|
||||
? 'Working…'
|
||||
: mode === 'invite'
|
||||
? 'Send invitation'
|
||||
: 'Create login'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
83
src/features/team/components/TeamTable.tsx
Normal file
83
src/features/team/components/TeamTable.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
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 {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
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 type {TeamMember} from '@/features/team/types/team';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
interface Row extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<Row>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Person',
|
||||
width: proportional(1),
|
||||
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',
|
||||
align: 'end',
|
||||
// Role IS an enumerated state, which is what a Badge is for — unlike a
|
||||
// status, which is a dot.
|
||||
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
|
||||
},
|
||||
];
|
||||
|
||||
export function TeamTable({resource}: {resource: Resource<TeamMember[]>}) {
|
||||
return (
|
||||
<PanelCard
|
||||
title="Team"
|
||||
subtitle="Who can sign in to this console, and at what privilege"
|
||||
resource={resource}
|
||||
loading={<SkeletonRows count={5} height={44} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="staff"
|
||||
title="No team members"
|
||||
description="People appear here once they accept an invitation."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(members) => (
|
||||
<ResponsiveTable
|
||||
data={members.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
email: m.email,
|
||||
role: m.role,
|
||||
}))}
|
||||
columns={COLUMNS}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
density="balanced"
|
||||
/>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
22
src/features/team/hooks/useTeam.ts
Normal file
22
src/features/team/hooks/useTeam.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {teamRepository} from '@/features/team/repositories/teamRepository';
|
||||
import type {Invitation, TeamMember} from '@/features/team/types/team';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
export function useTeam(): Resource<TeamMember[]> {
|
||||
return useResource(teamRepository.list());
|
||||
}
|
||||
|
||||
/**
|
||||
* Invitations that have been sent and not yet redeemed.
|
||||
*
|
||||
* A separate resource from the member list rather than one merged feed: they
|
||||
* are different things. A member can sign in; an invitation is a promise that
|
||||
* somebody could. Merging them would put rows in the access list for people who
|
||||
* have no access, which is the opposite of what that list is for.
|
||||
*/
|
||||
export function useInvitations(): Resource<Invitation[]> {
|
||||
return useResource(teamRepository.invitations());
|
||||
}
|
||||
44
src/features/team/repositories/teamRepository.ts
Normal file
44
src/features/team/repositories/teamRepository.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
deleteJson,
|
||||
patchJson,
|
||||
postJson,
|
||||
type Endpoint,
|
||||
} from '@/shared/services/httpClient';
|
||||
import type {Invitation, TeamMember} from '@/features/team/types/team';
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/** What a direct create hands back — the member, and the password, once. */
|
||||
export interface NewMember extends TeamMember {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** Console accounts. Unscoped — team membership is per company, not per site. */
|
||||
export const teamRepository = {
|
||||
list: (): Endpoint<TeamMember[]> => ({path: '/api/team', params: {}}),
|
||||
|
||||
invitations: (): Endpoint<Invitation[]> => ({
|
||||
path: '/api/team/invitations',
|
||||
params: {},
|
||||
}),
|
||||
|
||||
/** Role change or deactivation. 409 `last_owner` is a real answer here. */
|
||||
update: (id: string, patch: {role?: UserRole; active?: boolean}) =>
|
||||
patchJson<TeamMember>(`/api/team/${encodeURIComponent(id)}`, patch),
|
||||
|
||||
/** Preferred: they redeem the code and pick their own password. */
|
||||
invite: (body: {email: string; full_name?: string; role: UserRole}) =>
|
||||
postJson<Invitation>('/api/team/invitations', body),
|
||||
|
||||
revokeInvitation: (id: string) =>
|
||||
deleteJson<null>(`/api/team/invitations/${encodeURIComponent(id)}`),
|
||||
|
||||
/** For somebody with no phone to redeem an invitation on. */
|
||||
create: (body: {email: string; full_name?: string; role: UserRole}) =>
|
||||
postJson<NewMember>('/api/team/members', body),
|
||||
|
||||
resetPassword: (id: string) =>
|
||||
postJson<{password: string}>(
|
||||
`/api/team/${encodeURIComponent(id)}/password`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
42
src/features/team/services/mapTeam.ts
Normal file
42
src/features/team/services/mapTeam.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type {ApiInvitation, ApiTeamMember} from '@/services/api/types';
|
||||
import type {Invitation, TeamMember} from '@/features/team/types/team';
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Platform shapes → what the team screen consumes.
|
||||
*
|
||||
* Extracted from the route file so the list route and the three write routes
|
||||
* map identically. They did not before: only the list mapped, so a member that
|
||||
* came back from a PATCH was a different shape from the same member in the
|
||||
* list, and the screen could not use one to update the other.
|
||||
*/
|
||||
export function toMember(m: ApiTeamMember): TeamMember {
|
||||
return {
|
||||
id: m.id,
|
||||
// Falls back to the address rather than rendering a blank cell: somebody
|
||||
// invited but not yet named still has to be identifiable.
|
||||
name: m.full_name || m.email,
|
||||
email: m.email,
|
||||
role: m.role as UserRole,
|
||||
active: m.active,
|
||||
// Null rather than '' — "never signed in" and "signed in at an unknown
|
||||
// time" are different facts, and the screen says so.
|
||||
lastLoginAt: m.last_login_at || null,
|
||||
createdAt: m.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function toInvitation(i: ApiInvitation): Invitation {
|
||||
return {
|
||||
id: i.id,
|
||||
email: i.email,
|
||||
name: i.full_name || i.email,
|
||||
role: i.role as UserRole,
|
||||
invitedBy: i.invited_by ?? null,
|
||||
expiresAt: i.expires_at,
|
||||
createdAt: i.created_at,
|
||||
// Present ONLY on the response that minted it. Every later read omits it,
|
||||
// because the platform keeps a hash and cannot show it again.
|
||||
code: i.code ?? null,
|
||||
};
|
||||
}
|
||||
45
src/features/team/types/team.ts
Normal file
45
src/features/team/types/team.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* A person with console access.
|
||||
*
|
||||
* Not a shop-floor employee. `active: false` means signed out immediately and
|
||||
* unable to sign back in; reactivating restores the account but not their old
|
||||
* sessions.
|
||||
*
|
||||
* `organisation` was removed: it mapped from `client_name`, which
|
||||
* `GET /api/team` does not send, so it was `undefined` on every row. Every
|
||||
* member of this list belongs to the caller's own company by definition — the
|
||||
* backend scopes the query by the session's tenant — so the field said nothing
|
||||
* even when it was populated.
|
||||
*/
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
/** false = signed out immediately and unable to sign back in. */
|
||||
active: boolean;
|
||||
/** Absent for somebody who has never signed in — that is data, not a gap. */
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending invitation — an account that does not exist yet.
|
||||
*
|
||||
* `code` is present on exactly one response, the one that created it. The
|
||||
* platform stores a hash, so a list read can never show it again; a screen that
|
||||
* misses its one chance to display it has to withdraw the invitation and mint
|
||||
* another.
|
||||
*/
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
invitedBy: string | null;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
code: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user