Team management UI
Team screens for apps. Members with roles and a careful remove, invitations with checked addresses and a link, roles with rights, teams as a tree and seats.
People, roles and seats in order
Team management is the page an admin opens to bring someone in, change a role or free a seat. These five screens handle it with care, a remove asks first and can be undone, and every email address is checked before an invitation goes out.
Members shows a table with search, role filter and status, a role per person and bulk changes, phones get cards. Invite people takes email addresses as checked chips, a role as cards, lists open invitations to send again or withdraw and offers a join link to copy or renew. Roles and rights lists roles with their people and rights by group with counts, to edit, duplicate or add, the owner role stays locked. Teams shows departments as a tree you walk with the arrow keys, with the lead and head count per team. Seats counts seats in use, held by invitations and free, by role, points to people away for a while and adds seats with the cost due today.
Name roles after what people do, editor or billing, not after levels. Show the cost of a new seat before the button, not after. Let admins undo a remove for a few seconds, mistakes happen in long lists.
Variants
Details
What Team management brings along, read from the registry and the source.
- Category
- Team and collaboration
- Area
- App
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/team-management.tsx
- npm packages
- None
- Languages
- English and German, 140 texts of its own
- Studio
- Adjust in code
- In collections
- App dashboardSaaS app screens
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TeamManagementProps interface from components/ui/team-management.tsx, with the comments from the code.
variantoptionalTeamManagementVariantmembersinviterolesteamsseats
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlerequiredstringsubtitleoptionalstringmeoptionalstringYour member id
nowoptionalstringISO date and time that counts as now
membersoptionalTeamMember[]rolesoptionalTeamRole[]permissionsoptionalTeamPermission[]invitesoptionalTeamInvite[]teamsoptionalTeamGroup[]seatsoptionalTeamSeatsinviteLinkoptionalstringinvite: the join link
inviteDaysoptionalnumberinvite: days until an invitation runs out
inactiveDaysoptionalnumberseats: members away this many days count as inactive
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on every change. Without it the block simulates the request.
Built-in texts
Team management writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 140 texts
| Key | English | German |
|---|---|---|
| emailHint | English name@company.com | German name@firma.de |
| permYes | English Allowed, | German Erlaubt, |
| permNo | English Not part of this role, | German Nicht Teil dieser Rolle, |
| invitePeople | English Invite people | German Personen einladen |
| search | English Search by name or email | German Nach Name oder E-Mail suchen |
| allRoles | English All roles | German Alle Rollen |
| role | English Role | German Rolle |
| status | English Status | German Status |
| all | English All | German Alle |
| active | English Active | German Aktiv |
| invited | English Invited | German Eingeladen |
| suspended | English Paused | German Pausiert |
| member | English Member | German Mitglied |
| lastActive | English Last active | German Zuletzt aktiv |
| never | English Not yet | German Noch nicht |
| twoFactor | English Two step sign in | German Anmeldung in zwei Schritten |
| twoOn | English Two step sign in is on | German Anmeldung in zwei Schritten ist an |
| twoOff | English Two step sign in is off | German Anmeldung in zwei Schritten ist aus |
| you | English you | German du |
| count | English {n} of {total} people | German {n} von {total} Personen |
| selected | English {n} selected | German {n} ausgewählt |
| selectAll | English Select every person shown | German Alle angezeigten Personen auswählen |
| select | English Select {name} | German {name} auswählen |
| changeRole | English Change role to | German Rolle ändern auf |
| remove | English Remove | German Entfernen |
| removeName | English Remove {name} | German {name} entfernen |
| removeTitle | English Remove {name} from the team? | German {name} aus dem Team entfernen? |
| removeMany | English Remove {n} people from the team? | German {n} Personen aus dem Team entfernen? |
| removeText | English They lose access right away. Their work stays in the workspace and you can invite them again any time. | German Der Zugang endet sofort. Ihre Arbeit bleibt im Workspace, und du kannst sie jederzeit wieder einladen. |
| removeConfirm | English Remove from team | German Aus dem Team entfernen |
| cancel | English Cancel | German Abbrechen |
| removed | English {name} was removed. | German {name} wurde entfernt. |
| removedMany | English {n} people were removed. | German {n} Personen wurden entfernt. |
| undo | English Undo | German Rückgängig |
| restored | English Back in the team. | German Wieder im Team. |
| roleChanged | English {name} is now {role}. | German {name} ist jetzt {role}. |
| locked | English The owner keeps every right. | German Der Inhaber behält alle Rechte. |
| noMatch | English Nobody matches. Try another name or role. | German Niemand passt. Versuche einen anderen Namen oder eine andere Rolle. |
| emails | English Email addresses | German E-Mail-Adressen |
| emailsHint | English Separate them with a comma, a space or Enter. Pasting a list works too. | German Mit Komma, Leerzeichen oder Enter trennen. Eine eingefügte Liste klappt auch. |
| invalid | English {n} addresses need a look | German {n} Adressen brauchen einen Blick |
| invalidOne | English 1 address needs a look | German 1 Adresse braucht einen Blick |
| bad | English Not an email address | German Keine E-Mail-Adresse |
| dup | English Already added | German Schon hinzugefügt |
| exists | English Already in the team | German Schon im Team |
| pendingAlready | English Already invited | German Schon eingeladen |
| message | English Personal note | German Persönliche Notiz |
| messageHint | English Shown in the invitation email | German Steht in der Einladungsmail |
| optional | English optional | German optional |
| send | English Send invitations | German Einladungen senden |
| sendN | English Send {n} invitations | German {n} Einladungen senden |
| sendOne | English Send 1 invitation | German 1 Einladung senden |
| sending | English Sending… | German Wird gesendet… |
| sentN | English {n} invitations are on their way. | German {n} Einladungen sind unterwegs. |
| sentOne | English The invitation is on its way. | German Die Einladung ist unterwegs. |
| pending | English Pending invitations | German Offene Einladungen |
| noPending | English Every invitation was answered. | German Alle Einladungen sind beantwortet. |
| sentBy | English Sent {when} by {name} | German Gesendet {when} von {name} |
| sentAgo | English Sent {when} | German Gesendet {when} |
| expires | English Runs out {when} | German Läuft ab {when} |
| expired | English Ran out | German Abgelaufen |
| resend | English Send again | German Erneut senden |
| resending | English Sending… | German Wird gesendet… |
| resent | English Sent again | German Erneut gesendet |
| resentNote | English Invitation to {email} sent again. | German Einladung an {email} erneut gesendet. |
| revoke | English Withdraw | German Zurückziehen |
| revokeAsk | English Withdraw it? | German Zurückziehen? |
| revokeYes | English Yes, withdraw | German Ja, zurückziehen |
| revoked | English Invitation to {email} withdrawn. | German Einladung an {email} zurückgezogen. |
| link | English Invite link | German Einladungslink |
| linkText | English Anyone with the link joins with the role you pick. | German Wer den Link hat, tritt mit der gewählten Rolle bei. |
| linkOn | English Link is on | German Link ist an |
| linkOff | English The link is off. Nobody can join with it. | German Der Link ist aus. Niemand kann damit beitreten. |
| copy | English Copy link | German Link kopieren |
| copied | English Copied | German Kopiert |
| copiedNote | English Link copied. | German Link kopiert. |
| newLink | English Make a new link | German Neuen Link erstellen |
| newLinkNote | English New link made. The old one no longer works. | German Neuer Link erstellt. Der alte gilt nicht mehr. |
| linkRole | English Joins as | German Tritt bei als |
| roles | English Roles | German Rollen |
| newRole | English New role | German Neue Rolle |
| newRoleName | English New role | German Neue Rolle |
| peopleN | English {n} people | German {n} Personen |
| personOne | English 1 person | German 1 Person |
| edit | English Edit role | German Rolle bearbeiten |
| duplicate | English Duplicate | German Duplizieren |
| copyOf | English {name} copy | German {name} Kopie |
| save | English Save role | German Rolle speichern |
| saving | English Saving… | German Wird gespeichert… |
| saved | English Role saved. | German Rolle gespeichert. |
| discard | English Discard changes | German Änderungen verwerfen |
| name | English Name | German Name |
| description | English Description | German Beschreibung |
| rights | English Rights | German Rechte |
| ofN | English {n} of {total} | German {n} von {total} |
| allIn | English Every right in {group} | German Alle Rechte in {group} |
| withRole | English People with this role | German Personen mit dieser Rolle |
| noneWithRole | English Nobody has this role yet. | German Noch hat niemand diese Rolle. |
| teams | English Teams | German Teams |
| expandAll | English Expand all | German Alle aufklappen |
| lead | English Lead | German Leitung |
| noLead | English No lead yet | German Noch keine Leitung |
| changeLead | English Change lead | German Leitung ändern |
| members | English Members | German Mitglieder |
| addMember | English Add to team | German Zum Team hinzufügen |
| pickPerson | English Pick a person | German Person wählen |
| added | English {name} joined {team}. | German {name} ist jetzt in {team}. |
| removedFrom | English {name} left {team}. | German {name} hat {team} verlassen. |
| leadSet | English {name} now leads {team}. | German {name} leitet jetzt {team}. |
| subTeams | English Teams inside | German Teams darin |
| back | English Back | German Zurück |
| seats | English Seats | German Plätze |
| seatsUsed | English {used} of {total} seats in use | German {used} von {total} Plätzen belegt |
| seatsFree | English {n} free | German {n} frei |
| seatsFull | English Every seat is taken | German Alle Plätze sind belegt |
| seatsPending | English {n} held by invitations | German {n} durch Einladungen belegt |
| activeSeats | English Active members | German Aktive Mitglieder |
| invitedSeats | English Open invitations | German Offene Einladungen |
| freeSeats | English Free | German Frei |
| byRole | English Seats by role | German Plätze nach Rolle |
| addSeats | English Add seats | German Plätze hinzufügen |
| seatsToAdd | English Seats to add | German Neue Plätze |
| fewer | English One seat less | German Ein Platz weniger |
| more | English One seat more | German Ein Platz mehr |
| perSeat | English {price} per seat and {interval} | German {price} je Platz und {interval} |
| month | English month | German Monat |
| year | English year | German Jahr |
| newTotal | English New total {n} seats, {price} per {interval} | German Neu {n} Plätze, {price} je {interval} |
| today | English Due today, for the days until {date} | German Heute fällig, für die Tage bis {date} |
| buy | English Add {n} seats | German {n} Plätze hinzufügen |
| buyOne | English Add 1 seat | German 1 Platz hinzufügen |
| buying | English Adding… | German Wird hinzugefügt… |
| bought | English {n} seats added. They are ready to use. | German {n} Plätze hinzugefügt. Sie sind sofort nutzbar. |
| inactive | English Seats you can free | German Plätze, die du freigeben kannst |
| inactiveText | English These people have not signed in for a while. | German Diese Personen haben sich länger nicht angemeldet. |
| awayFor | English Away for {n} days | German Seit {n} Tagen weg |
| free | English Free up seat | German Platz freigeben |
| freed | English {name} is paused and the seat is free. | German {name} ist pausiert, der Platz ist frei. |
| noInactive | English Everyone signed in lately. Every seat is in use. | German Alle haben sich zuletzt angemeldet. Jeder Platz wird genutzt. |
| renews | English Renews {date} | German Verlängert sich am {date} |
Accessibility
Read from the source of the component.
- Team management handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Tab, Backspace and Space itself.
- Sets aria-checked, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-level, aria-live and aria-selected, so screen readers announce purpose and state.
- Uses the roles alertdialog, group, listbox, option, radio, radiogroup, status, tree and treeitem.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 1,900 lines
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { CheckboxBox } from '@/components/ui/checkbox';
import { Dialog } from '@/components/ui/dialog';
import { Icon } from '@/components/ui/icon';
import { IconButton } from '@/components/ui/icon-button';
import { Switch } from '@/components/ui/switch';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type TeamManagementVariant = 'members' | 'invite' | 'roles' | 'teams' | 'seats';
export interface TeamMember {
id: string;
name: string;
email: string;
/** Role id */
role: string;
status?: 'active' | 'invited' | 'suspended';
/** ISO date and time */
lastActive?: string;
twoFactor?: boolean;
}
export interface TeamRole {
id: string;
name: string;
description?: string;
/** Permission ids */
permissions: string[];
/** Owner like roles that cannot be changed or given away here */
locked?: boolean;
}
export interface TeamPermission {
id: string;
label: string;
/** Heading the permission is listed under */
group: string;
description?: string;
}
export interface TeamInvite {
id: string;
email: string;
/** Role id */
role: string;
/** ISO date and time */
sentAt: string;
/** Member id of who sent it */
by?: string;
}
export interface TeamGroup {
id: string;
name: string;
description?: string;
/** Member id */
lead?: string;
/** Member ids */
members: string[];
/** Id of the team above */
parent?: string;
}
export interface TeamSeats {
total: number;
/** Price of one seat per interval */
price: number;
currency?: string;
interval?: 'month' | 'year';
/** ISO date of the next renewal */
renews: string;
}
export interface TeamManagementProps {
variant?: TeamManagementVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** Your member id */
me?: string;
/** ISO date and time that counts as now */
now?: string;
members?: TeamMember[];
roles?: TeamRole[];
permissions?: TeamPermission[];
invites?: TeamInvite[];
teams?: TeamGroup[];
seats?: TeamSeats;
/** invite: the join link */
inviteLink?: string;
/** invite: days until an invitation runs out */
inviteDays?: number;
/** seats: members away this many days count as inactive */
inactiveDays?: number;
/** Called on every change. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
emailHint: 'name@company.com',
permYes: 'Allowed,',
permNo: 'Not part of this role,',
invitePeople: 'Invite people',
search: 'Search by name or email',
allRoles: 'All roles',
role: 'Role',
status: 'Status',
all: 'All',
active: 'Active',
invited: 'Invited',
suspended: 'Paused',
member: 'Member',
lastActive: 'Last active',
never: 'Not yet',
twoFactor: 'Two step sign in',
twoOn: 'Two step sign in is on',
twoOff: 'Two step sign in is off',
you: 'you',
count: '{n} of {total} people',
selected: '{n} selected',
selectAll: 'Select every person shown',
select: 'Select {name}',
changeRole: 'Change role to',
remove: 'Remove',
removeName: 'Remove {name}',
removeTitle: 'Remove {name} from the team?',
removeMany: 'Remove {n} people from the team?',
removeText: 'They lose access right away. Their work stays in the workspace and you can invite them again any time.',
removeConfirm: 'Remove from team',
cancel: 'Cancel',
removed: '{name} was removed.',
removedMany: '{n} people were removed.',
undo: 'Undo',
restored: 'Back in the team.',
roleChanged: '{name} is now {role}.',
locked: 'The owner keeps every right.',
noMatch: 'Nobody matches. Try another name or role.',
emails: 'Email addresses',
emailsHint: 'Separate them with a comma, a space or Enter. Pasting a list works too.',
invalid: '{n} addresses need a look',
invalidOne: '1 address needs a look',
bad: 'Not an email address',
dup: 'Already added',
exists: 'Already in the team',
pendingAlready: 'Already invited',
message: 'Personal note',
messageHint: 'Shown in the invitation email',
optional: 'optional',
send: 'Send invitations',
sendN: 'Send {n} invitations',
sendOne: 'Send 1 invitation',
sending: 'Sending…',
sentN: '{n} invitations are on their way.',
sentOne: 'The invitation is on its way.',
pending: 'Pending invitations',
noPending: 'Every invitation was answered.',
sentBy: 'Sent {when} by {name}',
sentAgo: 'Sent {when}',
expires: 'Runs out {when}',
expired: 'Ran out',
resend: 'Send again',
resending: 'Sending…',
resent: 'Sent again',
resentNote: 'Invitation to {email} sent again.',
revoke: 'Withdraw',
revokeAsk: 'Withdraw it?',
revokeYes: 'Yes, withdraw',
revoked: 'Invitation to {email} withdrawn.',
link: 'Invite link',
linkText: 'Anyone with the link joins with the role you pick.',
linkOn: 'Link is on',
linkOff: 'The link is off. Nobody can join with it.',
copy: 'Copy link',
copied: 'Copied',
copiedNote: 'Link copied.',
newLink: 'Make a new link',
newLinkNote: 'New link made. The old one no longer works.',
linkRole: 'Joins as',
roles: 'Roles',
newRole: 'New role',
newRoleName: 'New role',
peopleN: '{n} people',
personOne: '1 person',
edit: 'Edit role',
duplicate: 'Duplicate',
copyOf: '{name} copy',
save: 'Save role',
saving: 'Saving…',
saved: 'Role saved.',
discard: 'Discard changes',
name: 'Name',
description: 'Description',
rights: 'Rights',
ofN: '{n} of {total}',
allIn: 'Every right in {group}',
withRole: 'People with this role',
noneWithRole: 'Nobody has this role yet.',
teams: 'Teams',
expandAll: 'Expand all',
lead: 'Lead',
noLead: 'No lead yet',
changeLead: 'Change lead',
members: 'Members',
addMember: 'Add to team',
pickPerson: 'Pick a person',
added: '{name} joined {team}.',
removedFrom: '{name} left {team}.',
leadSet: '{name} now leads {team}.',
subTeams: 'Teams inside',
back: 'Back',
seats: 'Seats',
seatsUsed: '{used} of {total} seats in use',
seatsFree: '{n} free',
seatsFull: 'Every seat is taken',
seatsPending: '{n} held by invitations',
activeSeats: 'Active members',
invitedSeats: 'Open invitations',
freeSeats: 'Free',
byRole: 'Seats by role',
addSeats: 'Add seats',
seatsToAdd: 'Seats to add',
fewer: 'One seat less',
more: 'One seat more',
perSeat: '{price} per seat and {interval}',
month: 'month',
year: 'year',
newTotal: 'New total {n} seats, {price} per {interval}',
today: 'Due today, for the days until {date}',
buy: 'Add {n} seats',
buyOne: 'Add 1 seat',
buying: 'Adding…',
bought: '{n} seats added. They are ready to use.',
inactive: 'Seats you can free',
inactiveText: 'These people have not signed in for a while.',
awayFor: 'Away for {n} days',
free: 'Free up seat',
freed: '{name} is paused and the seat is free.',
noInactive: 'Everyone signed in lately. Every seat is in use.',
renews: 'Renews {date}',
},
de: {
emailHint: 'name@firma.de',
permYes: 'Erlaubt,',
permNo: 'Nicht Teil dieser Rolle,',
invitePeople: 'Personen einladen',
search: 'Nach Name oder E-Mail suchen',
allRoles: 'Alle Rollen',
role: 'Rolle',
status: 'Status',
all: 'Alle',
active: 'Aktiv',
invited: 'Eingeladen',
suspended: 'Pausiert',
member: 'Mitglied',
lastActive: 'Zuletzt aktiv',
never: 'Noch nicht',
twoFactor: 'Anmeldung in zwei Schritten',
twoOn: 'Anmeldung in zwei Schritten ist an',
twoOff: 'Anmeldung in zwei Schritten ist aus',
you: 'du',
count: '{n} von {total} Personen',
selected: '{n} ausgewählt',
selectAll: 'Alle angezeigten Personen auswählen',
select: '{name} auswählen',
changeRole: 'Rolle ändern auf',
remove: 'Entfernen',
removeName: '{name} entfernen',
removeTitle: '{name} aus dem Team entfernen?',
removeMany: '{n} Personen aus dem Team entfernen?',
removeText: 'Der Zugang endet sofort. Ihre Arbeit bleibt im Workspace, und du kannst sie jederzeit wieder einladen.',
removeConfirm: 'Aus dem Team entfernen',
cancel: 'Abbrechen',
removed: '{name} wurde entfernt.',
removedMany: '{n} Personen wurden entfernt.',
undo: 'Rückgängig',
restored: 'Wieder im Team.',
roleChanged: '{name} ist jetzt {role}.',
locked: 'Der Inhaber behält alle Rechte.',
noMatch: 'Niemand passt. Versuche einen anderen Namen oder eine andere Rolle.',
emails: 'E-Mail-Adressen',
emailsHint: 'Mit Komma, Leerzeichen oder Enter trennen. Eine eingefügte Liste klappt auch.',
invalid: '{n} Adressen brauchen einen Blick',
invalidOne: '1 Adresse braucht einen Blick',
bad: 'Keine E-Mail-Adresse',
dup: 'Schon hinzugefügt',
exists: 'Schon im Team',
pendingAlready: 'Schon eingeladen',
message: 'Persönliche Notiz',
messageHint: 'Steht in der Einladungsmail',
optional: 'optional',
send: 'Einladungen senden',
sendN: '{n} Einladungen senden',
sendOne: '1 Einladung senden',
sending: 'Wird gesendet…',
sentN: '{n} Einladungen sind unterwegs.',
sentOne: 'Die Einladung ist unterwegs.',
pending: 'Offene Einladungen',
noPending: 'Alle Einladungen sind beantwortet.',
sentBy: 'Gesendet {when} von {name}',
sentAgo: 'Gesendet {when}',
expires: 'Läuft ab {when}',
expired: 'Abgelaufen',
resend: 'Erneut senden',
resending: 'Wird gesendet…',
resent: 'Erneut gesendet',
resentNote: 'Einladung an {email} erneut gesendet.',
revoke: 'Zurückziehen',
revokeAsk: 'Zurückziehen?',
revokeYes: 'Ja, zurückziehen',
revoked: 'Einladung an {email} zurückgezogen.',
link: 'Einladungslink',
linkText: 'Wer den Link hat, tritt mit der gewählten Rolle bei.',
linkOn: 'Link ist an',
linkOff: 'Der Link ist aus. Niemand kann damit beitreten.',
copy: 'Link kopieren',
copied: 'Kopiert',
copiedNote: 'Link kopiert.',
newLink: 'Neuen Link erstellen',
newLinkNote: 'Neuer Link erstellt. Der alte gilt nicht mehr.',
linkRole: 'Tritt bei als',
roles: 'Rollen',
newRole: 'Neue Rolle',
newRoleName: 'Neue Rolle',
peopleN: '{n} Personen',
personOne: '1 Person',
edit: 'Rolle bearbeiten',
duplicate: 'Duplizieren',
copyOf: '{name} Kopie',
save: 'Rolle speichern',
saving: 'Wird gespeichert…',
saved: 'Rolle gespeichert.',
discard: 'Änderungen verwerfen',
name: 'Name',
description: 'Beschreibung',
rights: 'Rechte',
ofN: '{n} von {total}',
allIn: 'Alle Rechte in {group}',
withRole: 'Personen mit dieser Rolle',
noneWithRole: 'Noch hat niemand diese Rolle.',
teams: 'Teams',
expandAll: 'Alle aufklappen',
lead: 'Leitung',
noLead: 'Noch keine Leitung',
changeLead: 'Leitung ändern',
members: 'Mitglieder',
addMember: 'Zum Team hinzufügen',
pickPerson: 'Person wählen',
added: '{name} ist jetzt in {team}.',
removedFrom: '{name} hat {team} verlassen.',
leadSet: '{name} leitet jetzt {team}.',
subTeams: 'Teams darin',
back: 'Zurück',
seats: 'Plätze',
seatsUsed: '{used} von {total} Plätzen belegt',
seatsFree: '{n} frei',
seatsFull: 'Alle Plätze sind belegt',
seatsPending: '{n} durch Einladungen belegt',
activeSeats: 'Aktive Mitglieder',
invitedSeats: 'Offene Einladungen',
freeSeats: 'Frei',
byRole: 'Plätze nach Rolle',
addSeats: 'Plätze hinzufügen',
seatsToAdd: 'Neue Plätze',
fewer: 'Ein Platz weniger',
more: 'Ein Platz mehr',
perSeat: '{price} je Platz und {interval}',
month: 'Monat',
year: 'Jahr',
newTotal: 'Neu {n} Plätze, {price} je {interval}',
today: 'Heute fällig, für die Tage bis {date}',
buy: '{n} Plätze hinzufügen',
buyOne: '1 Platz hinzufügen',
buying: 'Wird hinzugefügt…',
bought: '{n} Plätze hinzugefügt. Sie sind sofort nutzbar.',
inactive: 'Plätze, die du freigeben kannst',
inactiveText: 'Diese Personen haben sich länger nicht angemeldet.',
awayFor: 'Seit {n} Tagen weg',
free: 'Platz freigeben',
freed: '{name} ist pausiert, der Platz ist frei.',
noInactive: 'Alle haben sich zuletzt angemeldet. Jeder Platz wird genutzt.',
renews: 'Verlängert sich am {date}',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
/*
* b5t-rise entrance of cards and rows, staggered with --b5t-d
* b5t-in something that appears after an action
* b5t-pop chips and marks
* b5t-out a row on its way out
* b5t-fill bars grow from the start
*/
const CSS =
'@keyframes b5t-rise{from{opacity:0;transform:translateY(12px)}}@keyframes b5t-pop{from{opacity:0;transform:scale(.7)}}@keyframes b5t-fill{from{transform:scaleX(0)}}@keyframes b5t-shake{25%,75%{transform:translateX(-4px)}50%{transform:translateX(4px)}}' +
'.b5t-fill{transform-origin:left}' +
'@media (prefers-reduced-motion:no-preference){.b5t-rise{animation:b5t-rise .6s var(--ease-kit) var(--b5t-d,0ms) both}.b5t-in{animation:b5t-rise .4s var(--ease-kit) both}.b5t-pop{animation:b5t-pop .35s cubic-bezier(.34,1.56,.64,1) both}.b5t-fill{animation:b5t-fill 1s var(--ease-kit) var(--b5t-d,150ms) both}.b5t-shake{animation:b5t-shake .35s ease-in-out}}';
function Style() {
return (
<style href="kitb5-team-management" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const MIN = 60000;
const toMs = (iso: string) => Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z'));
const intl = (l: Locale) => (l === 'de' ? 'de-DE' : 'en-US');
const wait = (ms: number) => new Promise((r) => setTimeout(r, prefersReducedMotion() ? 0 : ms));
async function run(onAction: TeamManagementProps['onAction'], action: string, data: Record<string, unknown>, ms = 700) {
if (onAction) await onAction(action, data);
else await wait(ms);
}
function ago(iso: string, now: string, l: Locale) {
const rtf = new Intl.RelativeTimeFormat(intl(l), { numeric: 'auto' });
const m = Math.round((toMs(iso) - toMs(now)) / MIN);
const a = Math.abs(m);
if (a < 60) return rtf.format(m, 'minute');
if (a < 1440) return rtf.format(Math.round(m / 60), 'hour');
if (a < 1440 * 45) return rtf.format(Math.round(m / 1440), 'day');
return rtf.format(Math.round(m / (1440 * 30)), 'month');
}
const days = (from: string, to: string) => Math.round((toMs(to) - toMs(from)) / (1440 * MIN));
const dateFmt = (iso: string, l: Locale) => new Intl.DateTimeFormat(intl(l), { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' }).format(new Date(toMs(iso)));
const money = (n: number, l: Locale, currency = 'EUR') => new Intl.NumberFormat(intl(l), { style: 'currency', currency, maximumFractionDigits: n % 1 ? 2 : 0, minimumFractionDigits: n % 1 ? 2 : 0 }).format(n);
const ring = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const ringIn = 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-2 focus-visible:outline-[color-mix(in_oklab,var(--primary)_70%,transparent)]';
const field = 'h-11 w-full min-w-0 rounded-lg border border-input bg-background px-3 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-9 sm:text-[13px]';
const accentText = 'text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]';
type Ctx = Omit<TeamManagementProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; locale: Locale; now: string };
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Header({ title, subtitle, children }: { title: string; subtitle?: string; children?: React.ReactNode }) {
return (
<div className="b5t-rise flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Card({ title, description, action, children, className, bodyClassName, delay = 0, headingLevel = 2 }: { title?: React.ReactNode; description?: React.ReactNode; action?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string; delay?: number; headingLevel?: 2 | 3 }) {
const H = headingLevel === 2 ? 'h2' : 'h3';
return (
<section className={cn('b5t-rise flex min-w-0 flex-col rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]', className)} style={{ '--b5t-d': `${delay}ms` } as React.CSSProperties}>
{(title || action) && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<div className="min-w-0">
{title && <H className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">{title}</H>}
{description && <p className="mt-0.5 text-pretty text-[13px] text-muted-foreground">{description}</p>}
</div>
{action}
</div>
)}
<div className={cn('flex-1 p-4 sm:p-5', bodyClassName)}>{children}</div>
</section>
);
}
function Pick({ label, value, onChange, children, className, disabled, hideLabel = true }: { label: string; value: string; onChange: (v: string) => void; children: React.ReactNode; className?: string; disabled?: boolean; hideLabel?: boolean }) {
return (
<label className={cn('relative block min-w-0', className)}>
<span className={hideLabel ? 'sr-only' : 'mb-1.5 block text-[12.5px] font-medium text-muted-foreground'}>{label}</span>
<span className="relative block">
<select disabled={disabled} value={value} onChange={(e) => onChange(e.target.value)} className={cn(field, 'cursor-pointer appearance-none truncate pe-8 hover:border-border-strong disabled:cursor-not-allowed disabled:bg-surface disabled:text-muted-foreground')}>
{children}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-2.5 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</label>
);
}
function SearchBox({ label, value, onChange, className }: { label: string; value: string; onChange: (v: string) => void; className?: string }) {
return (
<label className={cn('relative block', className)}>
<span className="sr-only">{label}</span>
<Icon name="search" size={15} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
<input type="search" value={value} onChange={(e) => onChange(e.target.value)} placeholder={label} className={cn(field, 'ps-9')} />
</label>
);
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('inline-flex rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + options.length) % options.length;
onChange(options[n].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[n]?.focus();
}}
className={cn('inline-flex h-11 flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-200 sm:h-8', value === o.value ? 'bg-background text-foreground shadow-[var(--shadow-sm)] ring-1 ring-border' : 'text-muted-foreground hover:text-foreground', ringIn)}
>
{o.label}
</button>
))}
</div>
);
}
const STATUS_TONE: Record<NonNullable<TeamMember['status']>, string> = {
active: 'bg-[color-mix(in_oklab,var(--success)_13%,transparent)] text-success-foreground',
invited: 'bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] ' + accentText,
suspended: 'bg-muted text-muted-foreground',
};
function StatusBadge({ s, t }: { s: NonNullable<TeamMember['status']>; t: Texts }) {
return (
<span className={cn('inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium', STATUS_TONE[s])}>
<span aria-hidden className={cn('size-1.5 rounded-full', s === 'active' ? 'bg-success' : s === 'invited' ? 'bg-primary' : 'bg-subtle-foreground')} />
{t[s]}
</span>
);
}
function RoleBadge({ name, strong }: { name: string; strong?: boolean }) {
return <span className={cn('inline-flex h-6 items-center rounded-md border px-2 text-[12px] font-medium', strong ? 'border-[color-mix(in_oklab,var(--primary)_35%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_8%,var(--card))] ' + accentText : 'border-border bg-surface text-muted-foreground')}>{name}</span>;
}
/** A note with an undo button, it stays a few seconds */
function UndoBar({ note, onUndo, onClose, t }: { note: string; onUndo?: () => void; onClose: () => void; t: Texts }) {
useEffect(() => {
const id = window.setTimeout(onClose, 7000);
return () => window.clearTimeout(id);
}, [note, onClose]);
return (
<div className="b5t-in fixed inset-x-3 bottom-3 z-40 mx-auto flex max-w-md items-center gap-3 rounded-xl border border-border bg-popover py-2 pe-2 ps-4 text-[13.5px] text-foreground shadow-[var(--highlight),var(--shadow-lg)] sm:bottom-6">
<Icon name="check-circle" size={16} className="shrink-0 text-success-foreground" />
<span className="min-w-0 flex-1">{note}</span>
{onUndo && (
<Button size="sm" variant="ghost" onClick={onUndo}>
{t.undo}
</Button>
)}
<IconButton icon="x" size="sm" label={t.cancel} onClick={onClose} />
</div>
);
}
/* ------------------------------------------------------------------ */
/* members */
/* ------------------------------------------------------------------ */
function Members(c: Ctx) {
const { t, locale, now, roles = [], me, onAction } = c;
const [list, setList] = useState(c.members ?? []);
const [q, setQ] = useState('');
const [role, setRole] = useState('all');
const [status, setStatus] = useState<'all' | 'active' | 'invited'>('all');
const [sel, setSel] = useState<string[]>([]);
const [confirm, setConfirm] = useState<string[] | null>(null);
const [undo, setUndo] = useState<{ note: string; restore?: TeamMember[] } | null>(null);
const [live, setLive] = useState('');
const roleOf = (id: string) => roles.find((r) => r.id === id);
const shown = list.filter((m) => (role === 'all' || m.role === role) && (status === 'all' || (m.status ?? 'active') === status) && (!q || `${m.name} ${m.email}`.toLowerCase().includes(q.toLowerCase())));
const locked = (m: TeamMember) => !!roleOf(m.role)?.locked || m.id === me;
const selectable = shown.filter((m) => !locked(m));
const allOn = selectable.length > 0 && selectable.every((m) => sel.includes(m.id));
const someOn = selectable.some((m) => sel.includes(m.id));
const setRoleFor = (ids: string[], r: string) => {
setList((l) => l.map((m) => (ids.includes(m.id) ? { ...m, role: r } : m)));
const name = ids.length === 1 ? list.find((m) => m.id === ids[0])?.name ?? '' : fill(t.selected, { n: ids.length });
setLive(fill(t.roleChanged, { name, role: roleOf(r)?.name ?? r }));
void run(onAction, 'role', { ids, role: r });
};
const doRemove = async (ids: string[]) => {
const gone = list.filter((m) => ids.includes(m.id));
setList((l) => l.filter((m) => !ids.includes(m.id)));
setSel((s) => s.filter((x) => !ids.includes(x)));
const note = gone.length === 1 ? fill(t.removed, { name: gone[0].name }) : fill(t.removedMany, { n: gone.length });
setUndo({ note, restore: gone });
setLive(note);
await run(onAction, 'remove', { ids });
};
const target = confirm?.length === 1 ? list.find((m) => m.id === confirm[0]) : undefined;
const roleOptions = roles.filter((r) => !r.locked);
const Last = ({ m }: { m: TeamMember }) => <span className="tabular-nums">{m.lastActive ? ago(m.lastActive, now, locale) : t.never}</span>;
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<Button size="sm" iconLeft="user-plus" magnetic onClick={() => void onAction?.('invite', {})}>
{t.invitePeople}
</Button>
</Header>
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-4">
<div className="b5t-rise grid grid-cols-[minmax(0,1fr)] gap-3 [--b5t-d:60ms] lg:flex lg:items-center lg:justify-between">
<SearchBox label={t.search} value={q} onChange={setQ} className="lg:w-72" />
<div className="grid grid-cols-[minmax(0,1fr)] gap-3 sm:flex sm:items-center">
<Pick label={t.role} value={role} onChange={setRole} className="sm:w-44">
<option value="all">{t.allRoles}</option>
{roles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
<Segmented
label={t.status}
value={status}
onChange={setStatus}
className="flex w-full sm:w-auto"
options={[
{ value: 'all', label: t.all },
{ value: 'active', label: t.active },
{ value: 'invited', label: <>{t.invited}<span className="text-[12px] tabular-nums text-subtle-foreground">{list.filter((m) => m.status === 'invited').length}</span></> },
]}
/>
</div>
</div>
{/* Bulk bar: only while people are selected */}
<div className={cn('grid transition-[grid-template-rows,opacity,margin] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', sel.length ? 'grid-rows-[1fr] opacity-100' : 'invisible -mt-4 grid-rows-[0fr] opacity-0')} inert={!sel.length}>
<div className="min-h-0 overflow-hidden">
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[color-mix(in_oklab,var(--primary)_30%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))] px-4 py-2.5">
<span className="text-[13.5px] font-semibold tabular-nums text-foreground">{fill(t.selected, { n: sel.length })}</span>
<span className="flex-1" />
<Pick label={t.changeRole} value="" onChange={(v) => v && setRoleFor(sel, v)} className="w-full sm:w-48">
<option value="">{t.changeRole}</option>
{roleOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
<Button size="sm" variant="destructive" iconLeft="trash" className="max-sm:w-full" onClick={() => setConfirm(sel)}>
{t.remove}
</Button>
</div>
</div>
</div>
<p className="text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
{fill(t.count, { n: shown.length, total: list.length })}
</p>
{/* Tablet and desktop: a table */}
<div className="b5t-rise overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] [--b5t-d:120ms] max-md:hidden">
<table className="w-full table-fixed text-left text-[13.5px]">
<thead className="border-b border-border bg-surface text-[12px] font-medium text-muted-foreground">
<tr>
<th scope="col" className="w-12 py-2.5 ps-4">
<label className="grid size-8 cursor-pointer place-items-center">
<span className="sr-only">{t.selectAll}</span>
<CheckboxBox checked={allOn} indeterminate={someOn && !allOn} onChange={() => setSel(allOn ? [] : selectable.map((m) => m.id))} />
</label>
</th>
<th scope="col" className="py-2.5 ps-2 font-medium">{t.member}</th>
<th scope="col" className="w-44 py-2.5 font-medium xl:w-52">{t.role}</th>
<th scope="col" className="w-32 py-2.5 font-medium">{t.status}</th>
<th scope="col" className="w-32 py-2.5 font-medium max-xl:hidden">{t.lastActive}</th>
<th scope="col" className="w-14 py-2.5 pe-3">
<span className="sr-only">{t.remove}</span>
</th>
</tr>
</thead>
<tbody>
{shown.map((m, i) => {
const lock = locked(m);
const on = sel.includes(m.id);
return (
<tr key={m.id} className={cn('b5t-rise border-b border-border transition-colors last:border-b-0', on ? 'bg-[color-mix(in_oklab,var(--primary)_5%,var(--card))]' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)]')} style={{ '--b5t-d': `${160 + i * 40}ms` } as React.CSSProperties}>
<td className="py-3 ps-4">
{!lock && (
<label className="grid size-8 cursor-pointer place-items-center">
<span className="sr-only">{fill(t.select, { name: m.name })}</span>
<CheckboxBox checked={on} onChange={() => setSel((s) => (on ? s.filter((x) => x !== m.id) : [...s, m.id]))} />
</label>
)}
</td>
<td className="py-3 ps-2">
<div className="flex min-w-0 items-center gap-3">
<Avatar name={m.name} size="sm" alt="" />
<div className="min-w-0">
<p className="flex items-center gap-1.5 truncate font-medium text-foreground">
<span className="truncate">{m.name}</span>
{m.id === me && <span className="shrink-0 text-[12px] font-normal text-subtle-foreground">({t.you})</span>}
{m.twoFactor !== undefined && <Icon name={m.twoFactor ? 'shield' : 'unlock'} size={13} label={m.twoFactor ? t.twoOn : t.twoOff} className={cn('shrink-0', m.twoFactor ? 'text-success-foreground' : 'text-subtle-foreground')} />}
</p>
<p className="truncate text-[12.5px] text-muted-foreground">{m.email}</p>
</div>
</div>
</td>
<td className="py-3 pe-3">
{lock && roleOf(m.role)?.locked ? (
<span title={t.locked}>
<RoleBadge name={roleOf(m.role)?.name ?? m.role} strong />
</span>
) : (
<Pick label={fill(t.changeRole, {}) + ' ' + m.name} value={m.role} onChange={(v) => setRoleFor([m.id], v)} disabled={m.id === me}>
{roleOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
)}
</td>
<td className="py-3">
<StatusBadge s={m.status ?? 'active'} t={t} />
</td>
<td className="py-3 text-[13px] text-muted-foreground max-xl:hidden">
<Last m={m} />
</td>
<td className="py-3 pe-3 text-right">{!lock && <IconButton icon="trash" size="sm" label={fill(t.removeName, { name: m.name })} onClick={() => setConfirm([m.id])} className="hover:text-destructive" />}</td>
</tr>
);
})}
</tbody>
</table>
{!shown.length && <p className="px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</p>}
</div>
{/* Phone: cards */}
<ul className="grid gap-3 md:hidden">
{shown.map((m, i) => {
const lock = locked(m);
const on = sel.includes(m.id);
return (
<li key={m.id} className={cn('b5t-rise rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)]', on ? 'border-[color-mix(in_oklab,var(--primary)_40%,var(--border))]' : 'border-border')} style={{ '--b5t-d': `${120 + i * 40}ms` } as React.CSSProperties}>
<div className="flex items-start gap-3">
<Avatar name={m.name} size="md" alt="" />
<div className="min-w-0 flex-1">
<p className="truncate text-[15px] font-semibold text-foreground">
{m.name}
{m.id === me && <span className="ms-1 text-[12px] font-normal text-subtle-foreground">({t.you})</span>}
</p>
<p className="truncate text-[13px] text-muted-foreground">{m.email}</p>
<p className="mt-2 flex flex-wrap items-center gap-2 text-[12.5px] text-subtle-foreground">
<StatusBadge s={m.status ?? 'active'} t={t} />
{m.lastActive && (
<span>
{t.lastActive} <Last m={m} />
</span>
)}
</p>
</div>
{!lock && (
<label className="-me-2 -mt-2 grid size-11 shrink-0 cursor-pointer place-items-center">
<span className="sr-only">{fill(t.select, { name: m.name })}</span>
<CheckboxBox checked={on} onChange={() => setSel((s) => (on ? s.filter((x) => x !== m.id) : [...s, m.id]))} />
</label>
)}
</div>
<div className="mt-3 flex items-center gap-2 border-t border-border pt-3">
{lock && roleOf(m.role)?.locked ? (
<p className="flex-1 text-[12.5px] text-muted-foreground">
<RoleBadge name={roleOf(m.role)?.name ?? ''} strong /> <span className="ms-1">{t.locked}</span>
</p>
) : (
<Pick label={`${t.role} ${m.name}`} value={m.role} onChange={(v) => setRoleFor([m.id], v)} className="flex-1" disabled={m.id === me}>
{roleOptions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
)}
{!lock && <IconButton icon="trash" size="md" variant="outline" label={fill(t.removeName, { name: m.name })} onClick={() => setConfirm([m.id])} />}
</div>
</li>
);
})}
{!shown.length && <li className="rounded-xl border border-dashed border-border px-4 py-10 text-center text-[13.5px] text-muted-foreground">{t.noMatch}</li>}
</ul>
</div>
<Dialog
open={!!confirm}
onOpenChange={(o) => !o && setConfirm(null)}
role="alertdialog"
icon="user-minus"
tone="destructive"
size="sm"
title={target ? fill(t.removeTitle, { name: target.name }) : fill(t.removeMany, { n: confirm?.length ?? 0 })}
description={t.removeText}
footer={(close) => (
<>
<Button variant="outline" onClick={close}>
{t.cancel}
</Button>
<Button
variant="destructive"
onClick={() => {
const ids = confirm ?? [];
close();
void doRemove(ids);
}}
>
{t.removeConfirm}
</Button>
</>
)}
/>
{undo && (
<UndoBar
note={undo.note}
t={t}
onClose={() => setUndo(null)}
onUndo={
undo.restore
? () => {
const back = undo.restore ?? [];
setList((l) => [...l, ...back].sort((a, b) => (c.members ?? []).findIndex((x) => x.id === a.id) - (c.members ?? []).findIndex((x) => x.id === b.id)));
setUndo(null);
setLive(t.restored);
void run(onAction, 'restore', { ids: back.map((m) => m.id) });
}
: undefined
}
/>
)}
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* invite */
/* ------------------------------------------------------------------ */
const EMAIL = /^[^\s@,;]+@[^\s@,;]+\.[a-z]{2,}$/i;
type Chip = { value: string; error?: 'bad' | 'dup' | 'exists' | 'pendingAlready' };
function EmailChips({ chips, setChips, t, id, known, pending, describedBy }: { chips: Chip[]; setChips: (fn: (c: Chip[]) => Chip[]) => void; t: Texts; id: string; known: string[]; pending: string[]; describedBy: string }) {
const [text, setText] = useState('');
const input = useRef<HTMLInputElement>(null);
const check = (v: string, list: Chip[]): Chip => {
const low = v.toLowerCase();
if (!EMAIL.test(v)) return { value: v, error: 'bad' };
if (list.some((c) => c.value.toLowerCase() === low)) return { value: v, error: 'dup' };
if (known.includes(low)) return { value: v, error: 'exists' };
if (pending.includes(low)) return { value: v, error: 'pendingAlready' };
return { value: v };
};
const add = (raw: string) => {
const parts = raw.split(/[\s,;]+/).map((s) => s.trim().replace(/^<|>$/g, '')).filter(Boolean);
if (!parts.length) return;
setChips((list) => parts.reduce<Chip[]>((acc, p) => [...acc, check(p, acc)], list));
setText('');
};
return (
<div onClick={() => input.current?.focus()} className="flex min-h-[5.5rem] cursor-text flex-wrap content-start items-center gap-1.5 rounded-lg border border-input bg-background p-2 shadow-[var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 focus-within:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-within:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)]">
<ul className="contents" aria-label={t.emails}>
{chips.map((c, i) => (
<li key={c.value + i} className={cn('b5t-pop inline-flex h-8 max-w-full items-center gap-1 rounded-md border ps-2 pe-0.5 text-[13px]', c.error ? 'b5t-shake border-[color-mix(in_oklab,var(--destructive)_45%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_8%,var(--card))] text-foreground' : 'border-border bg-surface text-foreground')}>
{c.error ? <Icon name="alert-circle" size={13} className="shrink-0 text-destructive" /> : <Avatar name={c.value} size="xs" alt="" className="size-5 text-[8px]" />}
<span className="truncate">{c.value}</span>
{c.error && <span className="shrink-0 text-[11.5px] text-destructive max-sm:sr-only">{t[c.error]}</span>}
{c.error && <span className="sr-only">{t[c.error]}</span>}
<IconButton icon="x" size="xs" label={fill(t.removeName, { name: c.value })} onClick={(e) => (e.stopPropagation(), setChips((l) => l.filter((_, k) => k !== i)), input.current?.focus())} className="size-7" />
</li>
))}
</ul>
<input
ref={input}
id={id}
type="email"
multiple
value={text}
aria-describedby={describedBy}
aria-invalid={chips.some((c) => c.error) || undefined}
onChange={(e) => {
const v = e.target.value;
if (/[\s,;]$/.test(v)) add(v);
else setText(v);
}}
onPaste={(e) => {
const v = e.clipboardData.getData('text');
if (/[\s,;]/.test(v.trim())) {
e.preventDefault();
add(text + v);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === 'Tab') {
if (text.trim()) {
e.preventDefault();
add(text);
}
} else if (e.key === 'Backspace' && !text && chips.length) setChips((l) => l.slice(0, -1));
}}
onBlur={() => text.trim() && add(text)}
placeholder={chips.length ? '' : t.emailHint}
className="h-11 min-w-[10rem] flex-1 bg-transparent px-1 text-base sm:h-8 text-foreground outline-none placeholder:text-subtle-foreground sm:text-[13.5px]"
/>
</div>
);
}
function RoleCards({ roles, value, onChange, label }: { roles: TeamRole[]; value: string; onChange: (v: string) => void; label: string }) {
const ref = useRef<HTMLDivElement>(null);
return (
<div ref={ref} role="radiogroup" aria-label={label} className="grid gap-2 sm:grid-cols-3">
{roles.map((r, i) => {
const on = r.id === value;
return (
<button
key={r.id}
type="button"
role="radio"
aria-checked={on}
tabIndex={on ? 0 : -1}
onClick={() => onChange(r.id)}
onKeyDown={(e) => {
const d = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!d) return;
e.preventDefault();
const n = (i + d + roles.length) % roles.length;
onChange(roles[n].id);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[n]?.focus();
}}
className={cn('relative rounded-lg border p-3 text-left transition-[border-color,background-color,box-shadow,translate] duration-200 hover:-translate-y-px', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_7%,var(--card))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : 'border-border bg-card hover:border-border-strong', ring)}
>
<span className="flex items-center justify-between gap-2">
<span className="text-[13.5px] font-semibold text-foreground">{r.name}</span>
<span className={cn('grid size-4 place-items-center rounded-full border transition-colors', on ? 'border-primary bg-primary' : 'border-border-strong')}>{on && <span className="b5t-pop size-1.5 rounded-full bg-primary-foreground" />}</span>
</span>
{r.description && <span className="mt-1 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{r.description}</span>}
</button>
);
})}
</div>
);
}
function Invite(c: Ctx) {
const { t, locale, now, roles = [], members = [], onAction } = c;
const id = useId();
const pickRoles = roles.filter((r) => !r.locked);
const [chips, setChips] = useState<Chip[]>([]);
const [role, setRole] = useState(pickRoles.find((r) => r.id === 'member')?.id ?? pickRoles[0]?.id ?? '');
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [pending, setPending] = useState(c.invites ?? []);
const [fresh, setFresh] = useState<string[]>([]);
const [resending, setResending] = useState('');
const [resent, setResent] = useState<string[]>([]);
const [asking, setAsking] = useState('');
const [linkOn, setLinkOn] = useState(true);
const [linkRole, setLinkRole] = useState(role);
const [link, setLink] = useState(c.inviteLink ?? 'https://app.northwind.io/join/7f3k-q9tm');
const [copied, setCopied] = useState(false);
const [live, setLive] = useState('');
const valid = chips.filter((x) => !x.error);
const bad = chips.length - valid.length;
const life = c.inviteDays ?? 7;
const name = (mid?: string) => members.find((m) => m.id === mid)?.name;
const send = async () => {
if (!valid.length) return;
setBusy(true);
await run(onAction, 'invite', { emails: valid.map((x) => x.value), role, note });
const added = valid.map((x, i) => ({ id: `i${Date.now()}${i}`, email: x.value, role, sentAt: now, by: c.me }));
setPending((p) => [...added, ...p]);
setFresh(added.map((a) => a.id));
setChips((l) => l.filter((x) => x.error));
setNote('');
setBusy(false);
setLive(added.length === 1 ? t.sentOne : fill(t.sentN, { n: added.length }));
};
return (
<Main>
<Header title={c.title} subtitle={c.subtitle} />
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)] xl:items-start">
<div className="grid gap-6">
<Card title={t.invitePeople} delay={60}>
<div className="grid gap-5">
<div className="grid gap-1.5">
<label htmlFor={`${id}-e`} className="text-[13px] font-medium text-foreground">
{t.emails}
</label>
<EmailChips chips={chips} setChips={setChips} t={t} id={`${id}-e`} known={members.map((m) => m.email.toLowerCase())} pending={pending.map((p) => p.email.toLowerCase())} describedBy={`${id}-eh`} />
<p id={`${id}-eh`} className={cn('text-[12.5px]', bad ? 'text-destructive' : 'text-muted-foreground')}>
{bad ? (bad === 1 ? t.invalidOne : fill(t.invalid, { n: bad })) : t.emailsHint}
</p>
</div>
<div className="grid gap-1.5">
<span className="text-[13px] font-medium text-foreground">{t.role}</span>
<RoleCards roles={pickRoles} value={role} onChange={setRole} label={t.role} />
</div>
<div className="grid gap-1.5">
<label htmlFor={`${id}-n`} className="text-[13px] font-medium text-foreground">
{t.message} <span className="font-normal text-subtle-foreground">({t.optional})</span>
</label>
<textarea id={`${id}-n`} rows={2} value={note} onChange={(e) => setNote(e.target.value)} aria-describedby={`${id}-nh`} className={cn(field, 'h-auto min-h-20 resize-y py-2 sm:h-auto')} />
<p id={`${id}-nh`} className="text-[12.5px] text-muted-foreground">
{t.messageHint}
</p>
</div>
<div className="flex justify-end border-t border-border pt-4">
<Button iconLeft={busy ? 'loader' : 'send'} disabled={!valid.length || busy} onClick={send} className="max-sm:w-full">
{busy ? t.sending : valid.length === 1 ? t.sendOne : valid.length ? fill(t.sendN, { n: valid.length }) : t.send}
</Button>
</div>
</div>
</Card>
<Card title={t.link} description={t.linkText} delay={140} action={<Switch checked={linkOn} onCheckedChange={(v) => (setLinkOn(v), void run(onAction, 'link', { on: v }))} aria-label={t.linkOn} />}>
{linkOn ? (
<div className="b5t-in grid gap-3">
<div className="flex flex-col gap-2 sm:flex-row">
<label className="min-w-0 flex-1">
<span className="sr-only">{t.link}</span>
<input readOnly value={link} onFocus={(e) => e.currentTarget.select()} className={cn(field, 'bg-surface text-muted-foreground')} />
</label>
<Button
variant={copied ? 'secondary' : 'outline'}
size="sm"
iconLeft={copied ? 'check' : 'copy'}
className="sm:h-9"
onClick={() => {
void navigator.clipboard?.writeText(link).catch(() => undefined);
setCopied(true);
setLive(t.copiedNote);
window.setTimeout(() => setCopied(false), 1800);
}}
>
{copied ? t.copied : t.copy}
</Button>
</div>
<div className="flex flex-wrap items-end justify-between gap-3">
<Pick label={t.linkRole} hideLabel={false} value={linkRole} onChange={setLinkRole} className="w-full sm:w-52">
{pickRoles.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</Pick>
<Button
variant="ghost"
size="sm"
iconLeft="refresh"
onClick={() => {
const tok = Math.random().toString(36).slice(2, 6) + '-' + Math.random().toString(36).slice(2, 6);
setLink(link.replace(/[^/]+$/, tok));
setLive(t.newLinkNote);
void run(onAction, 'new-link', {});
}}
>
{t.newLink}
</Button>
</div>
</div>
) : (
<p className="b5t-in flex items-center gap-2 text-[13px] text-muted-foreground">
<Icon name="lock" size={15} className="shrink-0" />
{t.linkOff}
</p>
)}
</Card>
</div>
<Card title={t.pending} delay={200} bodyClassName="p-0 sm:p-0" action={<span className="text-[12.5px] tabular-nums text-muted-foreground">{pending.length}</span>} className="xl:sticky xl:top-6">
{pending.length ? (
<ul>
{pending.map((p, i) => {
const left = life - days(p.sentAt, now);
const expired = left <= 0;
const r = roles.find((x) => x.id === p.role);
return (
<li key={p.id} className={cn('flex flex-col gap-3 border-b border-border px-4 py-3.5 last:border-b-0 sm:flex-row sm:items-center sm:px-5 xl:flex-col xl:items-stretch 2xl:flex-row 2xl:items-center', fresh.includes(p.id) ? 'b5t-in bg-[color-mix(in_oklab,var(--primary)_4%,var(--card))]' : 'b5t-rise')} style={{ '--b5t-d': `${240 + i * 40}ms` } as React.CSSProperties}>
<div className="flex min-w-0 flex-1 items-center gap-3">
<Avatar name={p.email} size="sm" alt="" tone="neutral" />
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2">
<span className="truncate text-[13.5px] font-medium text-foreground">{p.email}</span>
{r && <RoleBadge name={r.name} />}
</p>
<p className="truncate text-[12.5px] text-muted-foreground">{resent.includes(p.id) ? t.resent : name(p.by) ? fill(t.sentBy, { when: ago(p.sentAt, now, locale), name: name(p.by)! }) : fill(t.sentAgo, { when: ago(p.sentAt, now, locale) })}</p>
<p className={cn('flex items-center gap-1 text-[12.5px]', expired ? 'font-medium text-warning-foreground' : 'text-subtle-foreground')}>
<Icon name={expired ? 'alert-circle' : 'clock'} size={12} className="shrink-0" />
{expired ? t.expired : fill(t.expires, { when: ago(new Date(toMs(p.sentAt) + life * 1440 * MIN).toISOString().slice(0, 16), now, locale) })}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-1.5 max-sm:ps-11 xl:ps-11 2xl:ps-0">
{asking === p.id ? (
<>
<span className="text-[12.5px] font-medium text-foreground max-sm:hidden">{t.revokeAsk}</span>
<Button size="sm" variant="ghost" onClick={() => setAsking('')}>
{t.cancel}
</Button>
<Button
size="sm"
variant="destructive"
autoFocus
onClick={() => {
setPending((l) => l.filter((x) => x.id !== p.id));
setAsking('');
setLive(fill(t.revoked, { email: p.email }));
void run(onAction, 'revoke', { id: p.id });
}}
>
{t.revokeYes}
</Button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
iconLeft={resending === p.id ? 'loader' : resent.includes(p.id) ? 'check' : 'refresh'}
disabled={resending === p.id || resent.includes(p.id)}
onClick={async () => {
setResending(p.id);
await run(onAction, 'resend', { id: p.id });
setPending((l) => l.map((x) => (x.id === p.id ? { ...x, sentAt: now } : x)));
setResent((l) => [...l, p.id]);
setResending('');
setLive(fill(t.resentNote, { email: p.email }));
}}
>
{resending === p.id ? t.resending : resent.includes(p.id) ? t.resent : t.resend}
</Button>
<Button size="sm" variant="ghost" onClick={() => setAsking(p.id)}>
{t.revoke}
</Button>
</>
)}
</div>
</li>
);
})}
</ul>
) : (
<p className="px-5 py-10 text-center text-[13.5px] text-muted-foreground">{t.noPending}</p>
)}
</Card>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* roles */
/* ------------------------------------------------------------------ */
function Roles(c: Ctx) {
const { t, permissions = [], members = [], onAction } = c;
const [roles, setRoles] = useState(c.roles ?? []);
const [current, setCurrent] = useState((c.roles ?? []).find((r) => !r.locked)?.id ?? c.roles?.[0]?.id ?? '');
const [draft, setDraft] = useState<TeamRole | null>(null);
const [state, setState] = useState<'idle' | 'busy' | 'saved'>('idle');
const [view, setView] = useState<'list' | 'detail'>('detail');
const [live, setLive] = useState('');
const listRef = useRef<HTMLUListElement>(null);
const id = useId();
const role = roles.find((r) => r.id === current) ?? roles[0];
const shown = draft ?? role;
const groups = useMemo(() => [...new Set(permissions.map((p) => p.group))], [permissions]);
const people = (rid: string) => members.filter((m) => m.role === rid);
const choose = (rid: string) => {
setCurrent(rid);
setDraft(null);
setState('idle');
setView('detail');
};
const dirty = !!draft && JSON.stringify(draft) !== JSON.stringify(role);
const toggle = (pid: string, on: boolean) => draft && setDraft({ ...draft, permissions: on ? [...new Set([...draft.permissions, pid])] : draft.permissions.filter((x) => x !== pid) });
if (!shown) return null;
const editing = !!draft;
const has = (pid: string) => shown.locked || shown.permissions.includes(pid);
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<Button
size="sm"
iconLeft="plus"
onClick={() => {
const nr: TeamRole = { id: `r${Date.now()}`, name: t.newRoleName, description: '', permissions: [] };
setRoles((l) => [...l, nr]);
setCurrent(nr.id);
setDraft(nr);
setView('detail');
}}
>
{t.newRole}
</Button>
</Header>
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[15rem_minmax(0,1fr)] lg:grid-cols-[17rem_minmax(0,1fr)] md:items-start">
<nav aria-label={t.roles} className={cn('b5t-rise md:sticky md:top-6', view === 'detail' && 'max-md:hidden')}>
<ul
ref={listRef}
role="listbox"
aria-label={t.roles}
className="grid gap-1 rounded-xl border border-border bg-card p-1.5 shadow-[var(--highlight),var(--shadow-sm)]"
onKeyDown={(e) => {
const i = roles.findIndex((r) => r.id === current);
const n = e.key === 'ArrowDown' ? i + 1 : e.key === 'ArrowUp' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? roles.length - 1 : null;
if (e.key === 'Enter') return setView('detail');
if (n === null) return;
e.preventDefault();
const k = Math.max(0, Math.min(roles.length - 1, n));
setCurrent(roles[k].id);
setDraft(null);
listRef.current?.querySelectorAll<HTMLElement>('[role=option]')[k]?.focus();
}}
>
{roles.map((r) => {
const on = r.id === current;
const n = people(r.id).length;
return (
<li key={r.id} role="option" aria-selected={on} tabIndex={on ? 0 : -1} onClick={() => choose(r.id)} className={cn('flex min-h-14 cursor-pointer items-center gap-3 rounded-lg px-3 py-2.5 transition-colors', on ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface))] ring-1 ring-inset ring-border' : 'hover:bg-surface', ringIn)}>
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', on ? 'bg-primary text-primary-foreground' : 'bg-surface-2 text-muted-foreground')}>
<Icon name={r.locked ? 'crown' : 'shield'} size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-foreground">{r.name}</span>
<span className="block text-[12px] tabular-nums text-muted-foreground">{n === 1 ? t.personOne : fill(t.peopleN, { n })}</span>
</span>
<Icon name="chevron-right" size={15} className="shrink-0 text-subtle-foreground md:hidden" />
</li>
);
})}
</ul>
</nav>
<section aria-labelledby={`${id}-h`} className={cn('grid min-w-0 gap-4', view === 'list' && 'max-md:hidden')}>
<button type="button" onClick={() => setView('list')} className={cn('-ms-2 flex h-11 w-fit items-center gap-0.5 rounded-lg pe-2.5 ps-1 text-[14px] font-medium text-foreground hover:bg-accent md:hidden', ring)}>
<Icon name="chevron-left" size={20} />
{t.roles}
</button>
<div key={shown.id} className="b5t-in rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
{editing ? (
<div className="grid gap-3 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
{t.name}
<input id={`${id}-h`} value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} className={field} />
</label>
<label className="grid gap-1.5 text-[12.5px] font-medium text-muted-foreground">
{t.description}
<input value={draft.description ?? ''} onChange={(e) => setDraft({ ...draft, description: e.target.value })} className={field} />
</label>
</div>
) : (
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h2 id={`${id}-h`} className="flex items-center gap-2 text-[19px] font-semibold tracking-[-0.02em] text-foreground">
{shown.name}
{shown.locked && <Icon name="lock" size={15} className="text-subtle-foreground" label={t.locked} />}
</h2>
{shown.description && <p className="mt-1 text-pretty text-[13.5px] text-muted-foreground">{shown.description}</p>}
{shown.locked && <p className="mt-2 text-[12.5px] text-muted-foreground">{t.locked}</p>}
</div>
{!shown.locked && (
<div className="flex shrink-0 gap-2">
<Button
size="sm"
variant="outline"
iconLeft="copy"
onClick={() => {
const copy: TeamRole = { ...shown, id: `r${Date.now()}`, name: fill(t.copyOf, { name: shown.name }), locked: false };
setRoles((l) => [...l, copy]);
setCurrent(copy.id);
setDraft(copy);
}}
>
{t.duplicate}
</Button>
<Button size="sm" iconLeft="edit" onClick={() => (setDraft({ ...shown }), setState('idle'))}>
{t.edit}
</Button>
</div>
)}
</div>
)}
{state === 'saved' && !editing && (
<p className="b5t-in mt-3 flex items-center gap-2 text-[13px] text-success-foreground">
<Icon name="check-circle" size={15} />
{t.saved}
</p>
)}
</div>
<div className="grid gap-4 xl:grid-cols-2">
{groups.map((g, gi) => {
const list = permissions.filter((p) => p.group === g);
const on = list.filter((p) => has(p.id)).length;
return (
<section key={g} className="b5t-rise overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]" style={{ '--b5t-d': `${80 + gi * 60}ms` } as React.CSSProperties}>
<div className="flex items-center gap-3 border-b border-border bg-surface px-4 py-2.5 sm:px-5">
{editing && (
<label className="-ms-2 grid size-9 cursor-pointer place-items-center">
<span className="sr-only">{fill(t.allIn, { group: g })}</span>
<CheckboxBox checked={on === list.length} indeterminate={on > 0 && on < list.length} onChange={() => setDraft({ ...draft!, permissions: on === list.length ? draft!.permissions.filter((x) => !list.some((p) => p.id === x)) : [...new Set([...draft!.permissions, ...list.map((p) => p.id)])] })} />
</label>
)}
<h3 className="flex-1 text-[13px] font-semibold text-foreground">{g}</h3>
<span className="text-[12px] tabular-nums text-muted-foreground">{fill(t.ofN, { n: on, total: list.length })}</span>
</div>
<ul>
{list.map((p) => {
const yes = has(p.id);
const inner = (
<>
<span className="min-w-0 flex-1">
<span className={cn('block text-[13.5px]', yes ? 'text-foreground' : 'text-muted-foreground')}>{p.label}</span>
{p.description && <span className="block text-pretty text-[12px] text-subtle-foreground">{p.description}</span>}
</span>
</>
);
return (
<li key={p.id} className="border-b border-border last:border-b-0">
{editing ? (
<label className="flex min-h-12 cursor-pointer items-center gap-3 px-4 py-2.5 transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_2.5%,transparent)] sm:px-5">
<CheckboxBox checked={yes} onChange={(e) => toggle(p.id, e.target.checked)} />
{inner}
</label>
) : (
<div className="flex min-h-12 items-center gap-3 px-4 py-2.5 sm:px-5">
<span className={cn('grid size-[18px] shrink-0 place-items-center rounded-full', yes ? 'bg-[color-mix(in_oklab,var(--success)_16%,transparent)] text-success-foreground' : 'bg-muted text-subtle-foreground')}>
<Icon name={yes ? 'check' : 'minus'} size={11} strokeWidth={2.6} />
</span>
<span className="sr-only">{yes ? t.permYes : t.permNo}</span>
{inner}
</div>
)}
</li>
);
})}
</ul>
</section>
);
})}
</div>
<section className="b5t-rise rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] [--b5t-d:240ms] sm:p-5">
<h3 className="text-[13px] font-semibold text-foreground">{t.withRole}</h3>
{people(shown.id).length ? (
<ul className="mt-3 flex flex-wrap gap-2">
{people(shown.id).map((m) => (
<li key={m.id} className="inline-flex h-9 items-center gap-2 rounded-full border border-border bg-surface pe-3 ps-1 text-[13px] text-foreground">
<Avatar name={m.name} size="xs" alt="" className="size-7 text-[10px]" />
{m.name}
</li>
))}
</ul>
) : (
<p className="mt-2 text-[13px] text-muted-foreground">{t.noneWithRole}</p>
)}
</section>
{editing && (
<div className="b5t-in sticky bottom-3 z-10 flex flex-col-reverse gap-2 rounded-xl border border-border bg-popover p-3 shadow-[var(--highlight),var(--shadow-lg)] sm:flex-row sm:justify-end">
<Button
variant="ghost"
onClick={() => {
if (!roles.find((r) => r.id === draft.id)?.name || (draft.permissions.length === 0 && !role?.permissions.length && role?.name === t.newRoleName)) {
setRoles((l) => l.filter((r) => r.id !== draft.id || r.permissions.length || r.name !== t.newRoleName));
}
setDraft(null);
}}
>
{t.discard}
</Button>
<Button
iconLeft={state === 'busy' ? 'loader' : 'check'}
disabled={state === 'busy' || !draft.name.trim() || (!dirty && draft.name !== t.newRoleName)}
onClick={async () => {
setState('busy');
await run(onAction, 'save-role', { role: draft });
setRoles((l) => l.map((r) => (r.id === draft.id ? draft : r)));
setDraft(null);
setState('saved');
setLive(t.saved);
}}
>
{state === 'busy' ? t.saving : t.save}
</Button>
</div>
)}
</section>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* teams */
/* ------------------------------------------------------------------ */
function Teams(c: Ctx) {
const { t, members = [], onAction } = c;
const [teams, setTeams] = useState(c.teams ?? []);
const roots = teams.filter((x) => !x.parent || !teams.some((y) => y.id === x.parent));
const kids = (pid: string) => teams.filter((x) => x.parent === pid);
const [open, setOpen] = useState<string[]>(() => roots.map((r) => r.id));
const [current, setCurrent] = useState(teams[1]?.id ?? teams[0]?.id ?? '');
const [focus, setFocus] = useState(current);
const [view, setView] = useState<'list' | 'detail'>('detail');
const [add, setAdd] = useState('');
const [live, setLive] = useState('');
const tree = useRef<HTMLUListElement>(null);
const person = (id?: string) => members.find((m) => m.id === id);
const team = teams.find((x) => x.id === current);
// Visible nodes in order, for the arrow keys
const visible: { node: TeamGroup; level: number }[] = [];
const walk = (list: TeamGroup[], level: number) =>
list.forEach((n) => {
visible.push({ node: n, level });
if (open.includes(n.id)) walk(kids(n.id), level + 1);
});
walk(roots, 1);
const total = (id: string): number => {
const set = new Set<string>();
const go = (tid: string) => {
teams.find((x) => x.id === tid)?.members.forEach((m) => set.add(m));
kids(tid).forEach((k) => go(k.id));
};
go(id);
return set.size;
};
const move = (id: string) => {
setFocus(id);
tree.current?.querySelector<HTMLElement>(`[data-node="${id}"]`)?.focus();
};
const onKey = (e: React.KeyboardEvent) => {
const i = visible.findIndex((v) => v.node.id === focus);
const cur = visible[i];
if (!cur) return;
const k = e.key;
const hasKids = kids(cur.node.id).length > 0;
const isOpen = open.includes(cur.node.id);
if (k === 'ArrowDown') move(visible[Math.min(visible.length - 1, i + 1)].node.id);
else if (k === 'ArrowUp') move(visible[Math.max(0, i - 1)].node.id);
else if (k === 'Home') move(visible[0].node.id);
else if (k === 'End') move(visible[visible.length - 1].node.id);
else if (k === 'ArrowRight') {
if (hasKids && !isOpen) setOpen((o) => [...o, cur.node.id]);
else if (hasKids) move(kids(cur.node.id)[0].id);
} else if (k === 'ArrowLeft') {
if (hasKids && isOpen) setOpen((o) => o.filter((x) => x !== cur.node.id));
else if (cur.node.parent) move(cur.node.parent);
} else if (k === 'Enter' || k === ' ') {
setCurrent(cur.node.id);
setView('detail');
} else if (k === '*') setOpen(teams.map((x) => x.id));
else return;
e.preventDefault();
};
const update = (id: string, fn: (g: TeamGroup) => TeamGroup) => setTeams((l) => l.map((g) => (g.id === id ? fn(g) : g)));
const node_ = (node: TeamGroup, level: number) => {
const sub = kids(node.id);
const isOpen = open.includes(node.id);
const lead = person(node.lead);
return (
<li
key={node.id}
role="treeitem"
aria-level={level}
aria-expanded={sub.length ? isOpen : undefined}
aria-selected={node.id === current}
data-node={node.id}
tabIndex={node.id === focus ? 0 : -1}
onFocus={(e) => e.target === e.currentTarget && setFocus(node.id)}
onClick={(e) => {
e.stopPropagation();
setCurrent(node.id);
setFocus(node.id);
setView('detail');
}}
className={cn('rounded-lg', ringIn)}
>
<div className={cn('group/node flex min-h-12 cursor-pointer items-center gap-2 rounded-lg py-1.5 pe-2 transition-colors', node.id === current ? 'bg-[color-mix(in_oklab,var(--primary)_10%,var(--surface))] ring-1 ring-inset ring-border' : 'hover:bg-surface')} style={{ paddingInlineStart: `${(level - 1) * 1.25 + 0.25}rem` }}>
{sub.length ? (
<span
aria-hidden
onClick={(e) => {
e.stopPropagation();
setOpen((o) => (isOpen ? o.filter((x) => x !== node.id) : [...o, node.id]));
}}
className="relative grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground after:absolute after:-inset-2 hover:bg-accent hover:text-foreground"
>
<Icon name="chevron-right" size={15} className={cn('transition-transform duration-300 ease-[var(--ease-kit)]', isOpen && 'rotate-90')} />
</span>
) : (
<span className="w-7 shrink-0" />
)}
<span className={cn('grid size-8 shrink-0 place-items-center rounded-lg', level === 1 ? 'bg-[color-mix(in_oklab,var(--primary)_14%,var(--surface-2))] ' + accentText : 'bg-surface-2 text-muted-foreground')}>
<Icon name={level === 1 ? 'building' : 'users'} size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-foreground">{node.name}</span>
<span className="block truncate text-[12px] text-muted-foreground">
{lead ? lead.name : t.noLead} · {total(node.id) === 1 ? t.personOne : fill(t.peopleN, { n: total(node.id) })}
</span>
</span>
</div>
{sub.length > 0 && isOpen && (
<ul role="group" className="b5t-in relative mt-0.5 grid gap-0.5">
<span aria-hidden className="absolute bottom-3 top-0 w-px bg-border" style={{ insetInlineStart: `${(level - 1) * 1.25 + 1.125}rem` }} />
{sub.map((s) => node_(s, level + 1))}
</ul>
)}
</li>
);
};
const outside = members.filter((m) => team && !team.members.includes(m.id));
return (
<Main>
<Header title={c.title} subtitle={c.subtitle} />
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 md:grid-cols-[17rem_minmax(0,1fr)] lg:grid-cols-[20rem_minmax(0,1fr)] md:items-start">
<div className={cn('b5t-rise rounded-xl border border-border bg-card p-1.5 shadow-[var(--highlight),var(--shadow-sm)] md:sticky md:top-6', view === 'detail' && 'max-md:hidden')}>
<div className="flex items-center justify-between px-2.5 py-1.5">
<h2 className="text-[12px] font-medium uppercase tracking-[0.06em] text-subtle-foreground">{t.teams}</h2>
<Button size="sm" variant="ghost" onClick={() => setOpen(teams.map((x) => x.id))}>
{t.expandAll}
</Button>
</div>
<ul ref={tree} role="tree" aria-label={t.teams} onKeyDown={onKey} className="grid gap-0.5">
{roots.map((r) => node_(r, 1))}
</ul>
</div>
{team && (
<section aria-label={team.name} className={cn('grid min-w-0 gap-4', view === 'list' && 'max-md:hidden')}>
<button type="button" onClick={() => setView('list')} className={cn('-ms-2 flex h-11 w-fit items-center gap-0.5 rounded-lg pe-2.5 ps-1 text-[14px] font-medium text-foreground hover:bg-accent md:hidden', ring)}>
<Icon name="chevron-left" size={20} />
{t.teams}
</button>
<div key={team.id} className="b5t-in rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] sm:p-5">
{team.parent && <p className="text-[12.5px] text-muted-foreground">{teams.find((x) => x.id === team.parent)?.name}</p>}
<h2 className="text-[20px] font-semibold tracking-[-0.025em] text-foreground">{team.name}</h2>
{team.description && <p className="mt-1 max-w-[42rem] text-pretty text-[13.5px] text-muted-foreground">{team.description}</p>}
<div className="mt-4 grid gap-3 border-t border-border pt-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)] sm:items-end md:grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<div className="flex items-center gap-3">
{person(team.lead) ? <Avatar name={person(team.lead)!.name} size="md" alt="" /> : <span className="grid size-10 place-items-center rounded-full border border-dashed border-border-strong text-subtle-foreground"><Icon name="user" size={16} /></span>}
<div className="min-w-0">
<p className="text-[12px] font-medium text-muted-foreground">{t.lead}</p>
<p className="truncate text-[14px] font-semibold text-foreground">{person(team.lead)?.name ?? t.noLead}</p>
</div>
</div>
<Pick
label={t.changeLead}
hideLabel={false}
value={team.lead ?? ''}
onChange={(v) => {
update(team.id, (g) => ({ ...g, lead: v, members: g.members.includes(v) ? g.members : [...g.members, v] }));
setLive(fill(t.leadSet, { name: person(v)?.name ?? '', team: team.name }));
void run(onAction, 'lead', { team: team.id, lead: v });
}}
>
{!team.lead && <option value="">{t.noLead}</option>}
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Pick>
</div>
</div>
{kids(team.id).length > 0 && (
<div className="b5t-rise [--b5t-d:80ms]">
<h3 className="mb-2 text-[13px] font-semibold text-foreground">{t.subTeams}</h3>
<ul className="grid gap-2 sm:grid-cols-2">
{kids(team.id).map((k) => (
<li key={k.id}>
<button
type="button"
onClick={() => {
setCurrent(k.id);
setFocus(k.id);
setOpen((o) => [...new Set([...o, team.id])]);
}}
className={cn('group/sub flex w-full items-center gap-3 rounded-xl border border-border bg-card p-3 text-left shadow-[var(--shadow-sm)] transition-[border-color,translate] duration-200 hover:-translate-y-0.5 hover:border-border-strong', ring)}
>
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-surface-2 text-muted-foreground">
<Icon name="users" size={16} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-semibold text-foreground">{k.name}</span>
<span className="block text-[12px] tabular-nums text-muted-foreground">{total(k.id) === 1 ? t.personOne : fill(t.peopleN, { n: total(k.id) })}</span>
</span>
<Icon name="arrow-right" size={15} className="text-subtle-foreground transition-transform group-hover/sub:translate-x-0.5" />
</button>
</li>
))}
</ul>
</div>
)}
<Card
title={t.members}
delay={140}
bodyClassName="p-0 sm:p-0"
action={
<div className="flex w-full items-center gap-2 xl:w-auto">
<Pick label={t.pickPerson} value={add} onChange={setAdd} className="min-w-0 flex-1 xl:w-48 xl:flex-none">
<option value="">{t.pickPerson}</option>
{outside.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Pick>
<Button
size="sm"
variant="outline"
iconLeft="plus"
disabled={!add}
className="sm:h-9"
onClick={() => {
update(team.id, (g) => ({ ...g, members: [...g.members, add] }));
setLive(fill(t.added, { name: person(add)?.name ?? '', team: team.name }));
void run(onAction, 'add-member', { team: team.id, member: add });
setAdd('');
}}
>
{t.addMember}
</Button>
</div>
}
>
<ul>
{team.members.map((mid, i) => {
const m = person(mid);
if (!m) return null;
const lead = mid === team.lead;
return (
<li key={mid} className="b5t-rise flex items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5" style={{ '--b5t-d': `${180 + i * 35}ms` } as React.CSSProperties}>
<Avatar name={m.name} size="sm" alt="" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-2">
<span className="truncate text-[13.5px] font-medium text-foreground">{m.name}</span>
{lead && <RoleBadge name={t.lead} strong />}
</span>
<span className="block truncate text-[12.5px] text-muted-foreground">{m.email}</span>
</span>
{!lead && (
<IconButton
icon="user-minus"
size="sm"
label={fill(t.removeName, { name: m.name })}
onClick={() => {
update(team.id, (g) => ({ ...g, members: g.members.filter((x) => x !== mid) }));
setLive(fill(t.removedFrom, { name: m.name, team: team.name }));
void run(onAction, 'remove-member', { team: team.id, member: mid });
}}
className="hover:text-destructive"
/>
)}
</li>
);
})}
</ul>
</Card>
</section>
)}
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* seats */
/* ------------------------------------------------------------------ */
function Seats(c: Ctx) {
const { t, locale, now, roles = [], onAction } = c;
const seats = c.seats ?? { total: 10, price: 12, renews: now.slice(0, 10) };
const [total, setTotal] = useState(seats.total);
const [members, setMembers] = useState(c.members ?? []);
const [extra, setExtra] = useState(3);
const [busy, setBusy] = useState(false);
const [live, setLive] = useState('');
const invites = c.invites ?? [];
const activeN = members.filter((m) => (m.status ?? 'active') === 'active').length;
const invitedN = members.filter((m) => m.status === 'invited').length + invites.length;
const used = activeN + invitedN;
const free = Math.max(0, total - used);
const share = (n: number) => `${Math.round((n / Math.max(total, used)) * 1000) / 10}%`;
const interval = seats.interval === 'year' ? t.year : t.month;
const cur = seats.currency ?? 'EUR';
const periodDays = seats.interval === 'year' ? 365 : 30;
const left = Math.max(0, Math.min(periodDays, days(now.slice(0, 10), seats.renews)));
const today = Math.round(((extra * seats.price * left) / periodDays) * 100) / 100;
const away = c.inactiveDays ?? 30;
const idle = members.filter((m) => (m.status ?? 'active') === 'active' && m.lastActive && days(m.lastActive, now) >= away).sort((a, b) => toMs(a.lastActive!) - toMs(b.lastActive!));
const byRole = roles.map((r) => ({ r, n: members.filter((m) => m.role === r.id && m.status !== 'suspended').length })).filter((x) => x.n);
const max = Math.max(1, ...byRole.map((x) => x.n));
const id = useId();
return (
<Main>
<Header title={c.title} subtitle={c.subtitle}>
<span className="text-[13px] text-muted-foreground">{fill(t.renews, { date: dateFmt(seats.renews, locale) })}</span>
</Header>
<div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)] lg:items-start">
<div className="grid gap-6">
<Card delay={60}>
<p className="text-[13px] font-medium text-muted-foreground">{t.seats}</p>
<p className="mt-1 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-[clamp(2rem,1.6rem+1.6vw,2.75rem)] font-semibold leading-none tracking-[-0.04em] text-foreground tabular-nums">
{used}
<span className="text-muted-foreground">/{total}</span>
</span>
<span className={cn('text-[14px] font-medium', free ? 'text-success-foreground' : 'text-warning-foreground')}>{free ? fill(t.seatsFree, { n: free }) : t.seatsFull}</span>
</p>
<p className="sr-only">{fill(t.seatsUsed, { used, total })}</p>
<div aria-hidden className="mt-5 flex h-3 overflow-hidden rounded-full bg-track">
<span key={`a${activeN}${total}`} className="b5t-fill h-full bg-primary" style={{ width: share(activeN) }} />
<span key={`i${invitedN}${total}`} className="b5t-fill h-full border-s-2 border-card bg-[color-mix(in_oklab,var(--primary)_40%,var(--card))] [--b5t-d:350ms]" style={{ width: share(invitedN) }} />
</div>
<ul className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-[13px]">
{[
{ label: t.activeSeats, n: activeN, dot: 'bg-primary' },
{ label: t.invitedSeats, n: invitedN, dot: 'bg-[color-mix(in_oklab,var(--primary)_40%,var(--card))]' },
{ label: t.freeSeats, n: free, dot: 'bg-track' },
].map((x) => (
<li key={x.label} className="flex items-center gap-2 text-muted-foreground">
<span className={cn('size-2.5 rounded-full', x.dot)} />
{x.label}
<span className="font-semibold tabular-nums text-foreground">{x.n}</span>
</li>
))}
</ul>
</Card>
<Card title={t.byRole} delay={120}>
<ul className="grid gap-3.5">
{byRole.map(({ r, n }, i) => (
<li key={r.id} className="grid grid-cols-[7rem_minmax(0,1fr)_2rem] items-center gap-3 text-[13px] sm:grid-cols-[9rem_minmax(0,1fr)_2rem]">
<span className="truncate text-foreground">{r.name}</span>
<span className="h-2 overflow-hidden rounded-full bg-track">
<span className="b5t-fill block h-full rounded-full bg-[color-mix(in_oklab,var(--primary)_75%,var(--card))]" style={{ width: `${Math.round((n / max) * 100)}%`, '--b5t-d': `${200 + i * 80}ms` } as React.CSSProperties} />
</span>
<span className="text-right font-semibold tabular-nums text-foreground">{n}</span>
</li>
))}
</ul>
</Card>
<Card title={t.inactive} description={t.inactiveText} delay={180} bodyClassName="p-0 sm:p-0">
{idle.length ? (
<ul>
{idle.map((m) => (
<li key={m.id} className="b5t-in flex flex-wrap items-center gap-3 border-b border-border px-4 py-3 last:border-b-0 sm:px-5">
<Avatar name={m.name} size="sm" alt="" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-medium text-foreground">{m.name}</span>
<span className="block text-[12.5px] text-warning-foreground">{fill(t.awayFor, { n: days(m.lastActive!, now) })}</span>
</span>
<Button
size="sm"
variant="outline"
onClick={() => {
setMembers((l) => l.map((x) => (x.id === m.id ? { ...x, status: 'suspended' } : x)));
setLive(fill(t.freed, { name: m.name }));
void run(onAction, 'free-seat', { id: m.id });
}}
>
{t.free}
</Button>
</li>
))}
</ul>
) : (
<p className="px-5 py-8 text-center text-[13.5px] text-muted-foreground">{t.noInactive}</p>
)}
</Card>
</div>
<Card title={t.addSeats} delay={140} className="lg:sticky lg:top-6">
<div className="grid gap-5">
<div>
<label htmlFor={`${id}-n`} className="text-[13px] font-medium text-foreground">
{t.seatsToAdd}
</label>
<div className="mt-2 flex items-center gap-2">
<IconButton icon="minus" variant="outline" label={t.fewer} disabled={extra <= 1} onClick={() => setExtra((n) => Math.max(1, n - 1))} />
<input
id={`${id}-n`}
type="number"
inputMode="numeric"
min={1}
max={500}
value={extra}
onChange={(e) => setExtra(Math.max(1, Math.min(500, Math.round(Number(e.target.value) || 1))))}
className={cn(field, 'h-11 w-20 text-center text-[17px] font-semibold tabular-nums sm:h-11 sm:text-[17px]')}
/>
<IconButton icon="plus" variant="outline" label={t.more} onClick={() => setExtra((n) => Math.min(500, n + 1))} />
</div>
<p className="mt-2 text-[12.5px] text-muted-foreground">{fill(t.perSeat, { price: money(seats.price, locale, cur), interval })}</p>
</div>
<dl className="grid gap-2 rounded-lg bg-surface p-3.5 text-[13px]">
<div className="flex items-baseline justify-between gap-3">
<dt className="text-muted-foreground">{fill(t.newTotal, { n: total + extra, price: money((total + extra) * seats.price, locale, cur), interval })}</dt>
</div>
<div key={extra} className="b5t-in flex items-baseline justify-between gap-3 border-t border-border pt-2">
<dt className="text-pretty text-foreground">{fill(t.today, { date: dateFmt(seats.renews, locale) })}</dt>
<dd className="shrink-0 text-[17px] font-semibold tabular-nums text-foreground">{money(today, locale, cur)}</dd>
</div>
</dl>
<Button
iconLeft={busy ? 'loader' : 'plus'}
disabled={busy}
onClick={async () => {
setBusy(true);
await run(onAction, 'add-seats', { seats: extra }, 900);
setTotal((n) => n + extra);
setBusy(false);
setLive(fill(t.bought, { n: extra }));
}}
>
{busy ? t.buying : extra === 1 ? t.buyOne : fill(t.buy, { n: extra })}
</Button>
</div>
</Card>
</div>
<Live text={live} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* TeamManagement */
/* ------------------------------------------------------------------ */
/**
* Team screens for apps: members in a table with roles and a careful remove,
* invitations with checked email chips and a join link, roles with their rights,
* teams as a tree with leads, and seats with the cost of more.
* <TeamManagement variant="members" title="Team" members={[…]} roles={[…]} />
*/
export function TeamManagement({ variant = 'members', sidebar, labels, className, now = '2026-10-23T14:30', ...props }: TeamManagementProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const c: Ctx = { ...props, t, locale, now };
const body = (() => {
switch (variant) {
case 'invite':
return <Invite {...c} />;
case 'roles':
return <Roles {...c} />;
case 'teams':
return <Teams {...c} />;
case 'seats':
return <Seats {...c} />;
default:
return <Members {...c} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<Style />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<Style />
{body}
</Sidebar>
);
}Frequent questions
How do I install Team management?
With aiw add @ai-website/team-management. The command puts components/ui/team-management.tsx into your project, the code is yours from then on. It brings along avatar, button, checkbox, dialog, icon, icon-button, switch and sidebar. It needs no further npm packages.
Which variants does Team management have?
5 variants, Members, Invite people, Roles and rights, Teams and Seats. You pick one with the variant prop, for example variant="invite".
Can I translate the texts of Team management?
Yes. Team management carries 140 texts of its own in English and German, such as “name@company.com”, “Allowed,” and “Not part of this role,”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Team management?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Team management with aiw_components and install it with aiw_add @ai-website/team-management. Name the variant in your prompt, for example variant="members", and the assistant writes your content straight into the props.