{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "team", "type": "registry:block", "title": "Team block", "description": "Member list, invites and role control over the same bring-your-own-auth boundary: status as word plus glyph, row-level role controls, self-actions disabled with the reason, and a mock invite that mints a link instead of claiming an email went out.", "dependencies": [ "@radix-ui/react-dialog@^1.1.23" ], "registryDependencies": [ "@venlyfinance/venly-tokens", "@venlyfinance/data-table", "@venlyfinance/status-pill", "@venlyfinance/auth" ], "files": [ { "path": "registry/blocks/team.tsx", "type": "registry:component", "target": "~/components/venly/blocks/team.tsx", "content": "import { useEffect, useState, type CSSProperties, type ReactElement } from \"react\";\nimport * as Dialog from \"@radix-ui/react-dialog\";\nimport { DataTable, RowText, type DataTableColumn } from \"../components/data-table.js\";\nimport { StatusPill, type StatusIntent } from \"../components/status-pill.js\";\nimport type { Role } from \"./auth.js\";\n\n/**\n * Team block – member list, invites and role control over a\n * bring-your-own-auth adapter.\n *\n * Like the auth block, this renders YOUR identity layer: the Venly APIs\n * authenticate machines, not people, so team membership lives with your\n * auth provider. Implement `TeamAdapter` over it; the mock ships for\n * demos and tests.\n *\n * Design contract encoded by this block:\n * - Member status is word + glyph (survives greyscale): Invited is a\n * pending intent, Disabled a neutral one – neither is red.\n * - Role and enablement controls live in the row (the last column is\n * live controls, not chrome).\n * - You cannot change your own role or disable yourself; the control is\n * disabled AND explains why, instead of failing on click.\n * - The mock invite never claims an email was sent: it mints a\n * display-only link and says exactly that.\n */\n\n// ── Adapter contract ───────────────────────────────────────────────────\n\nexport type TeamMemberStatus = \"ACTIVE\" | \"INVITED\" | \"DISABLED\";\n\nexport interface TeamMember {\n id: string;\n name: string;\n email: string;\n role: Role;\n status: TeamMemberStatus;\n /** Mock-only: a display-only invite link for demo hand-off. */\n inviteUrl?: string;\n}\n\nexport interface TeamAdapter {\n members(): Promise;\n invite(input: { email: string; role: Role }): Promise;\n setRole(id: string, role: Role): Promise;\n setEnabled(id: string, enabled: boolean): Promise;\n}\n\n// ── Mock adapter ───────────────────────────────────────────────────────\n\n/**\n * Seeded with three members – an active admin (treat as the signed-in\n * user), a pending invite, and a disabled viewer – so every status the\n * table renders exists on first paint. Invites mint a display-only URL;\n * no email is sent and the UI never claims one was.\n */\nexport function createMockTeamAdapter(): TeamAdapter {\n let counter = 0;\n const members: TeamMember[] = [\n {\n id: \"tm-1\",\n name: \"Ada Lovelace\",\n email: \"ada@acme.example\",\n role: \"ADMIN\",\n status: \"ACTIVE\",\n },\n {\n id: \"tm-2\",\n name: \"Casey Jones\",\n email: \"casey@acme.example\",\n role: \"MANAGER\",\n status: \"INVITED\",\n inviteUrl: \"https://invite.example/team/tm-2\",\n },\n {\n id: \"tm-3\",\n name: \"Riley Chen\",\n email: \"riley@acme.example\",\n role: \"VIEWER\",\n status: \"DISABLED\",\n },\n ];\n\n const find = (id: string): TeamMember => {\n const member = members.find((m) => m.id === id);\n if (!member) throw new Error(`No team member with id ${id}.`);\n return member;\n };\n\n return {\n async members() {\n return members.map((m) => ({ ...m }));\n },\n async invite(input) {\n counter += 1;\n const id = `tm-invite-${counter}`;\n const member: TeamMember = {\n id,\n name: input.email.split(\"@\")[0] ?? input.email,\n email: input.email,\n role: input.role,\n status: \"INVITED\",\n inviteUrl: `https://invite.example/team/${id}`,\n };\n members.push(member);\n return { ...member };\n },\n async setRole(id, role) {\n const member = find(id);\n member.role = role;\n return { ...member };\n },\n async setEnabled(id, enabled) {\n const member = find(id);\n member.status = enabled ? \"ACTIVE\" : \"DISABLED\";\n return { ...member };\n },\n };\n}\n\n// ── Role copy (rendered wherever a role is chosen) ─────────────────────\n\nexport const ROLE_DESCRIPTIONS: Record = {\n ADMIN: \"Approves money movement and manages the team\",\n MANAGER: \"Creates and edits requests\",\n VIEWER: \"Read-only\",\n};\n\nconst STATUS_PILL: Record = {\n ACTIVE: { label: \"Active\", intent: \"positive\" },\n INVITED: { label: \"Invited\", intent: \"pending\" },\n DISABLED: { label: \"Disabled\", intent: \"neutral\" },\n};\n\n// ── Shared styling ─────────────────────────────────────────────────────\n\nconst selectStyle: CSSProperties = {\n border: \"var(--border-w-hairline) solid var(--border-strong)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-2xs) var(--space-sm)\",\n fontSize: \"var(--font-size-label)\",\n fontFamily: \"var(--font-family)\",\n color: \"var(--text-primary)\",\n background: \"var(--surface-raised)\",\n};\n\nconst quietButton: CSSProperties = {\n border: \"var(--border-w-hairline) solid var(--border-strong)\",\n background: \"var(--surface-raised)\",\n color: \"var(--text-primary)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-2xs) var(--space-sm)\",\n fontSize: \"var(--font-size-label)\",\n fontFamily: \"var(--font-family)\",\n cursor: \"pointer\",\n};\n\nconst primaryButton: CSSProperties = {\n border: \"none\",\n background: \"var(--accent)\",\n color: \"var(--accent-fg)\",\n borderRadius: \"var(--radius-control)\",\n padding: \"var(--space-sm) var(--space-lg)\",\n fontSize: \"var(--font-size-body)\",\n fontFamily: \"var(--font-family)\",\n fontWeight: 500,\n cursor: \"pointer\",\n};\n\n// ── Invite dialog ──────────────────────────────────────────────────────\n\nexport interface InviteDialogProps {\n adapter: TeamAdapter;\n /** Fires with the created member so the list can refresh/announce. */\n onInvited: (member: TeamMember) => void;\n style?: CSSProperties;\n className?: string;\n}\n\nexport function InviteDialog({\n adapter,\n onInvited,\n style,\n className,\n}: InviteDialogProps): ReactElement {\n const [open, setOpen] = useState(false);\n const [email, setEmail] = useState(\"\");\n const [role, setRole] = useState(\"VIEWER\");\n const [submitting, setSubmitting] = useState(false);\n\n return (\n {\n setOpen(next);\n if (next) {\n setEmail(\"\");\n setRole(\"VIEWER\");\n }\n }}\n >\n \n \n \n \n \n \n \n Invite a team member\n \n {\n e.preventDefault();\n setSubmitting(true);\n void adapter.invite({ email, role }).then((member) => {\n setSubmitting(false);\n setOpen(false);\n onInvited(member);\n });\n }}\n >\n
\n \n Email\n \n setEmail(e.target.value)}\n />\n
\n \n \n Role at invite time\n \n {(Object.keys(ROLE_DESCRIPTIONS) as Role[]).map((r) => (\n \n setRole(r)}\n />\n \n {roleLabel(r)}{\" \"}\n \n – {ROLE_DESCRIPTIONS[r]}\n \n \n \n ))}\n \n
\n \n \n \n \n
\n \n \n
\n \n );\n}\n\nfunction roleLabel(role: Role): string {\n return role.charAt(0) + role.slice(1).toLowerCase();\n}\n\n// ── Team table ─────────────────────────────────────────────────────────\n\nexport interface TeamTableViewProps {\n members: TeamMember[] | null;\n /** The signed-in user's email; their own row's controls lock. */\n currentUserEmail: string;\n adapter: TeamAdapter;\n /** Called after any adapter mutation so the owner can refetch. */\n onChanged: () => void;\n style?: CSSProperties;\n className?: string;\n}\n\n/** Presentational half: the table over already-loaded members. */\nexport function TeamTableView({\n members,\n currentUserEmail,\n adapter,\n onChanged,\n style,\n className,\n}: TeamTableViewProps): ReactElement {\n const [selfNote, setSelfNote] = useState(false);\n const [announce, setAnnounce] = useState(null);\n const [inviteUrl, setInviteUrl] = useState(null);\n\n const refresh = onChanged;\n\n const isSelf = (m: TeamMember): boolean =>\n m.email.toLowerCase() === currentUserEmail.toLowerCase();\n\n const columns: DataTableColumn[] = [\n {\n key: \"member\",\n header: \"Member\",\n cell: (m) => ,\n },\n {\n key: \"status\",\n header: \"Status\",\n cell: (m) => ,\n },\n {\n key: \"role\",\n header: \"Role\",\n cell: (m) =>\n isSelf(m) ? (\n \n {roleLabel(m.role)} (you)\n \n ) : (\n {\n void adapter.setRole(m.id, e.target.value as Role).then((updated) => {\n setAnnounce(`${updated.name} is now ${roleLabel(updated.role)}.`);\n refresh();\n });\n }}\n >\n {(Object.keys(ROLE_DESCRIPTIONS) as Role[]).map((r) => (\n \n ))}\n \n ),\n },\n {\n key: \"controls\",\n header: \"\",\n cell: (m) =>\n isSelf(m) ? (\n setSelfNote(true)}\n >\n Disable\n \n ) : (\n {\n const enable = m.status === \"DISABLED\";\n void adapter.setEnabled(m.id, enable).then((updated) => {\n setAnnounce(`${updated.name} is now ${STATUS_PILL[updated.status].label.toLowerCase()}.`);\n refresh();\n });\n }}\n >\n {m.status === \"DISABLED\" ? \"Enable\" : \"Disable\"}\n \n ),\n },\n ];\n\n return (\n
\n \n \n Team\n \n {\n setAnnounce(`Invite created for ${member.email}.`);\n setInviteUrl(member.inviteUrl ?? null);\n refresh();\n }}\n />\n \n {selfNote ? (\n \n You can't change your own role. Ask another admin.\n

\n ) : null}\n {announce ? (\n \n {announce}\n {inviteUrl ? (\n \n {\" \"}\n Invite link created – in this demo, share it directly:{\" \"}\n {inviteUrl}\n \n ) : null}\n

\n ) : null}\n {members === null ? (\n

\n Loading team…\n

\n ) : (\n \n m.id} />\n \n )}\n
\n );\n}\n\nexport interface TeamTableProps {\n adapter: TeamAdapter;\n /** The signed-in user's email; their own row's controls lock. */\n currentUserEmail: string;\n style?: CSSProperties;\n className?: string;\n}\n\n/** Connected block: loads members from the adapter and keeps them fresh. */\nexport function TeamTable({ adapter, currentUserEmail, style, className }: TeamTableProps): ReactElement {\n const [members, setMembers] = useState(null);\n const refresh = (): void => {\n void adapter.members().then(setMembers);\n };\n useEffect(refresh, [adapter]);\n return (\n \n );\n}\n" } ] }