Equipo

Miembros del equipo e invitaciones para los ajustes del espacio de trabajo de un producto de IA. Un medidor de plazas que previsualiza las invitaciones que estás escribiendo, un cuadro de invitación que convierte una lista pegada en chips verificados, miembros con roles, búsqueda, filtros y ordenación, invitaciones pendientes con esperas para reenviar y enlaces copiables, transferencia de propiedad con confirmación escrita y eliminación con deshacer.

Todo producto de IA con espacios de trabajo acaba teniendo la misma página: quién está en el equipo, qué puede hacer cada persona, a quién se ha invitado y cuántas plazas quedan. Team es esa página como un solo componente que integras en una sección de ajustes. Lee tus miembros, invitaciones y número de plazas, y llama a tus handlers cuando alguien actúa. Nunca cambia los datos por sí solo, así que lo que muestra es siempre lo que dice tu servidor.

El cuadro de invitación admite las direcciones tal como las tiene la gente. Escribe y pulsa coma, espacio o Enter, o pega una columna de una hoja de cálculo, una línea To: de un correo o una lista con nombres como Ana Lima <[email protected]>. Cada dirección se convierte en un chip. Los duplicados se descartan, las palabras que no son direcciones se omiten al pegar, y los chips de direcciones inválidas, miembros existentes y personas que ya tienen una invitación se marcan con el motivo, además de un botón para quitarlos todos a la vez. Elige el rol, añade una nota opcional y envía.

El medidor de plazas muestra los miembros, las invitaciones pendientes y los chips que estás escribiendo como tres capas, para que veas el coste de una invitación antes de enviarla. Las invitaciones caducadas no ocupan plaza. Cuando las invitaciones superarían el límite, el envío se bloquea indicando cuántas quitar, y al llegar al límite el medidor se convierte en una llamada a la acción Add seats.

Cada fila de miembro tiene el rol de la persona como un menú con lo que puede hacer cada rol. Las salvaguardas están integradas: el único propietario no puede degradarse a sí mismo ni salir hasta que transfiera la propiedad, y los menús lo dicen en lugar de ocultar la opción en silencio. Los administradores no pueden tocar a los propietarios. Hacer propietario a alguien o bajar tu propio rol pregunta primero. Transferir la propiedad te pide escribir el correo del nuevo propietario, para que la Ana equivocada nunca reciba las llaves.

Las invitaciones pendientes muestran cuándo se enviaron, cuándo están a punto de caducar y cuándo lo hicieron. Resend renueva el enlace e inicia una espera contada desde el último envío, así que la cuenta atrás sobrevive a una recarga. Copy invite link copia el enlace real en el portapapeles. Eliminar a un miembro pregunta una vez y luego ofrece Undo, y el foco pasa a la siguiente persona en lugar de volver a la parte superior de la página.

  1. Añade el registro Pro a components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Añade tu token

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  3. Añade el bloque

    pnpm dlx shadcn@latest add @hextaui-pro/team

Conéctalo a tu API

TeamSettings es controlado: pasa members, invites y seats, y actualízalos en cada handler. Devuelve { email: message } desde onInvite para conservar las direcciones que tu servidor rechazó, con el motivo en cada chip.

"use client"

import * as React from "react"

import { SettingsSection, SettingsShell } from "../settings/settings"
import { TeamSettings, type TeamInvite, type TeamMember } from "@/components/blocks/team/team"

type Team = { seats: number; members: TeamMember[]; invites: TeamInvite[] }

async function call<Result = void>(path: string, init?: RequestInit) {
  const response = await fetch(path, {
    ...init,
    headers: { "content-type": "application/json" },
  })
  if (!response.ok) throw new Error((await response.text()) || "Try again in a moment.")
  return (response.status === 204 ? undefined : await response.json()) as Result
}

export function TeamPage({ team: initial, userId }: { team: Team; userId: string }) {
  const [team, setTeam] = React.useState(initial)
  const reload = async () => setTeam(await call<Team>("/api/team"))

  return (
    <SettingsShell sections={[{ id: "team", label: "Members" }]} className="h-svh">
      <SettingsSection id="team">
        <TeamSettings
          members={team.members}
          invites={team.invites}
          seats={team.seats}
          currentUserId={userId}
          onInvite={async (request) => {
            const { errors } = await call<{ errors: Record<string, string> }>("/api/team/invites", {
              method: "POST",
              body: JSON.stringify(request),
            })
            await reload()
            return errors
          }}
          onRoleChange={async (id, role) => {
            await call(`/api/team/members/${id}`, { method: "PATCH", body: JSON.stringify({ role }) })
            await reload()
          }}
          onRemove={async (id) => {
            await call(`/api/team/members/${id}`, { method: "DELETE" })
            await reload()
          }}
          onResend={async (id) => {
            await call(`/api/team/invites/${id}/resend`, { method: "POST" })
            await reload()
          }}
          onRevoke={async (id) => {
            await call(`/api/team/invites/${id}`, { method: "DELETE" })
            await reload()
          }}
          upgradeHref="/billing?add=seats"
        />
      </SettingsSection>
    </SettingsShell>
  )
}

Solo lo que permites

Cada acción aparece solo cuando su handler está definido, y solo para quienes pueden realizarla. Sin onInvite no hay cuadro de invitación, y los miembros y espectadores ven la lista sin menús.

"use client"

import { TeamSettings, type TeamMember } from "@/components/blocks/team/team"

const members: TeamMember[] = [
  { id: "u1", name: "Mia Chen", email: "[email protected]", role: "owner", lastActiveAt: Date.now() },
  { id: "u2", name: "Jonas Weber", email: "[email protected]", role: "admin", lastActiveAt: null },
]

export function ReadOnlyTeam({ userId }: { userId: string }) {
  return <TeamSettings members={members} currentUserId={userId} seats={5} />
}

export function OwnerTeam({ userId }: { userId: string }) {
  return (
    <TeamSettings
      members={members}
      currentUserId={userId}
      seats={5}
      onRoleChange={async () => {}}
      onRemove={async () => {}}
      onTransferOwnership={async (id) => {
        await fetch(`/api/team/owner`, { method: "PUT", body: JSON.stringify({ id }) })
      }}
    />
  )
}

Deshacer una eliminación

Pasa onRestore y el toast tras una eliminación recibe un botón Undo que te entrega el miembro eliminado para volver a añadirlo.

"use client"

import * as React from "react"

import { TeamSettings, type TeamMember } from "@/components/blocks/team/team"

export function TeamWithUndo({ initial, userId }: { initial: TeamMember[]; userId: string }) {
  const [members, setMembers] = React.useState(initial)

  return (
    <TeamSettings
      members={members}
      currentUserId={userId}
      onRemove={async (id) => {
        await fetch(`/api/team/members/${id}`, { method: "DELETE" })
        setMembers((current) => current.filter((member) => member.id !== id))
      }}
      onRestore={async (member) => {
        await fetch(`/api/team/members`, { method: "POST", body: JSON.stringify(member) })
        setMembers((current) => [...current, member])
      }}
    />
  )
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
TeamSettingsLa página completa: plazas, el cuadro de invitación, invitaciones pendientes, miembros y la fila opcional de unión automática por dominio. Colócala dentro de un SettingsSection.
parseEmailListEl analizador detrás del cuadro de invitación. Divide el texto pegado o escrito en direcciones en minúsculas, desenvuelve las formas Name <email> y mailto:, y al pegar omite las palabras que no son direcciones.
teamRolesLos cuatro roles con sus etiquetas y descripciones, ordenados de mayor a menor acceso.
PropTipoPredeterminado
members{ id, name, email, role, avatarUrl?, lastActiveAt?, joinedAt? }. lastActiveAt null muestra Not yet active.
TeamMember[]–
invites{ id, email, role, sentAt, expiresAt, link? }. Las horas aceptan un Date, una cadena ISO o milisegundos. link habilita Copy invite link.
TeamInvite[][]
currentUserIdEl miembro con sesión iniciada. Su rol decide qué acciones aparecen.
string–
seatsPlazas del plan. Los miembros y las invitaciones sin caducar usan una cada uno. Omítelo para plazas ilimitadas.
number–
domainMuestra una fila que permite unirse sin invitación a quienes tengan un correo de empresa verificado.
{ name, verified, autoJoin, role? }–
resendCooldownSegundos tras enviar una invitación antes de poder enviarla de nuevo.
number60
pageSizeCuántos miembros se muestran antes de Show more.
number50
onInviteAñade el cuadro de invitación. Devuelve mensajes por correo para las direcciones que tu servidor rechazó; permanecen como chips con el mensaje. Lanza un error para conservar todos los chips y mostrar el error en el botón.
({ emails, role, note }) => void | { [email]: message } | Promise–
onRoleChangeAñade el menú de roles. La fila muestra un spinner hasta que se resuelve, y un toast si lanza un error.
(memberId, role) => void | Promise–
onRemoveAñade Remove from team, y Leave team en tu propia fila. Si lanza un error, el diálogo permanece abierto con el mensaje.
(memberId) => void | Promise–
onRestoreAñade Undo al toast tras una eliminación, con el miembro tal como estaba.
(member) => void | Promise–
onTransferOwnershipAñade Transfer ownership para los propietarios, confirmado escribiendo el correo del nuevo propietario.
(memberId) => void | Promise–
onResendAñade Resend. Actualiza sentAt y expiresAt para que empiece la espera y se renueve el enlace.
(inviteId) => void | Promise–
onRevokeAñade Revoke invite.
(inviteId) => void | Promise–
onAutoJoinChangeHace funcionar el switch de dominio. Se sacude y se queda donde está si esto lanza un error.
(enabled) => void | Promise–
onUpgradeAñade un botón Add seats, que se convierte en la acción principal cuando todas las plazas están ocupadas.
() => void–
upgradeHrefHace que Add seats sea un enlace en su lugar.
string–
classNameClases para el contenedor, una columna de grupos.
string–

parseEmailList

Devuelve { emails, skipped }.

PropTipoPredeterminado
textCualquier cosa que contenga direcciones, separadas por comas, puntos y coma, espacios, tabuladores o saltos de línea.
string–
options.pasteOmite las palabras sin @ cuando hay al menos una dirección, y las cuenta en skipped.
booleanfalse
KeyAcción
EnterEn el cuadro de invitación, convierte lo que escribiste en un chip. Púlsalo de nuevo con el cuadro vacío para enviar.
,También convierte lo que escribiste en un chip, igual que el espacio y el punto y coma.
BackspaceEn un cuadro de invitación vacío, pasa al último chip. Backspace o Delete allí lo elimina y pasa al anterior.
⌘EnterEnvía las invitaciones desde el cuadro de direcciones o la nota. Ctrl+Enter en Windows y Linux.
EscapeBorra la búsqueda de miembros, o cierra un menú o diálogo abierto.
  • Las filas de miembros son elementos de lista con el nombre de la persona. Los menús de roles se nombran como «Admin, change role for Ana Lima» y el botón de más como «More actions for Ana Lima».
  • Los chips que no se pueden enviar llevan su motivo en el nombre, y la lista de problemas bajo el cuadro está asociada al input como su descripción.
  • Añadir direcciones, enviar, cambiar roles y eliminar se anuncian de forma polite en una sola región live. Los errores en los diálogos se anuncian como alertas.
  • Las acciones deshabilitadas se explican solas: el menú de roles y el menú de más opciones indican por qué el único propietario no puede cambiar su rol ni salir, y Resend sigue siendo enfocable durante su espera con el tiempo restante en su nombre.
  • Tras una eliminación, el foco pasa a las acciones del siguiente miembro, o del anterior al final de la lista. Revocar una invitación hace lo mismo con las invitaciones.
  • El medidor de plazas es un meter con su etiqueta y el recuento como texto. Con movimiento reducido, cambia sin deslizarse.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Team. Cada uno se instala por separado.

Código

3 archivos, añadidos a components/blocks/team.