first commit

This commit is contained in:
2026-09-25 16:31:10 +05:30
parent e43cebb43e
commit 88f658fba9
378 changed files with 33242 additions and 115 deletions

View 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>
);
}

View 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>
);
}

View 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());
}

View 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`,
{},
),
};

View 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,
};
}

View 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;
}