Equipe

Membros da equipe e convites para as configurações do workspace de um produto de IA. Um medidor de vagas que pré-visualiza os convites que você está digitando, uma caixa de convite que transforma uma lista colada em chips verificados, membros com funções, busca, filtros e ordenação, convites pendentes com intervalo para reenvio e links copiáveis, transferência de propriedade com confirmação digitada e remoção com desfazer.

Todo produto de IA com workspaces acaba com a mesma página: quem está na equipe, o que cada pessoa pode fazer, quem foi convidado e quantas vagas restam. O Team é essa página como um único componente que você encaixa em uma seção de configurações. Ele lê seus membros, convites e a contagem de vagas, e chama os seus handlers quando alguém age. Nunca altera os dados por conta própria, então o que mostra é sempre o que o seu servidor diz.

A caixa de convite aceita endereços do jeito que as pessoas realmente os têm. Digite e pressione vírgula, espaço ou Enter, ou cole uma coluna de uma planilha, uma linha Para: de um e-mail ou uma lista com nomes como Ana Lima <[email protected]>. Cada endereço vira um chip. Duplicatas são descartadas, palavras que não são endereços são ignoradas ao colar, e os chips de endereços inválidos, membros existentes e pessoas que já têm um convite são sinalizados com o motivo, além de um botão para removê-los de uma vez. Escolha a função, adicione uma nota opcional e envie.

O medidor de vagas mostra os membros, os convites pendentes e os chips que você está digitando como três camadas, para que você veja o custo de um convite antes de enviá-lo. Convites expirados não ocupam vaga. Quando os convites ultrapassariam o limite, o envio é bloqueado informando quantos remover, e no limite o medidor se transforma em um call to action de Add seats.

Cada linha de membro tem a função da pessoa como um menu com o que cada função pode fazer. As proteções já vêm incluídas: o único proprietário não pode rebaixar a si mesmo nem sair até transferir a propriedade, e os menus dizem isso em vez de esconder a opção em silêncio. Administradores não podem mexer nos proprietários. Tornar alguém proprietário ou reduzir a sua própria função pergunta antes. Transferir a propriedade pede que você digite o e-mail do novo proprietário, para que a Ana errada nunca receba as chaves.

Os convites pendentes mostram quando foram enviados, quando estão para expirar e quando expiraram. Resend renova o link e inicia um intervalo de espera contado a partir do último envio, então a contagem regressiva sobrevive a um recarregamento. Copy invite link coloca o link real na área de transferência. Remover um membro pergunta uma vez e depois oferece Undo, e o foco vai para a próxima pessoa em vez de voltar ao topo da página.

  1. Adicione o registro Pro ao components.json

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

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  3. Adicione o bloco

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

Conecte à sua API

TeamSettings é controlado: passe members, invites e seats e atualize-os em cada handler. Retorne { email: message } de onInvite para manter os endereços que o seu servidor recusou, com o motivo em 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>
  )
}

Somente o que você permite

Cada ação só aparece quando seu handler está definido, e apenas para quem pode executá-la. Sem onInvite não há caixa de convite, e membros e visualizadores veem a lista sem menus.

"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 }) })
      }}
    />
  )
}

Desfazer uma remoção

Passe onRestore e o toast após uma remoção ganha um botão Undo que entrega o membro removido para você adicioná-lo de volta.

"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])
      }}
    />
  )
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
TeamSettingsA página inteira: vagas, a caixa de convite, convites pendentes, membros e a linha opcional de entrada automática por domínio. Coloque-a dentro de um SettingsSection.
parseEmailListO parser por trás da caixa de convite. Divide o texto colado ou digitado em endereços em minúsculas, desembrulha as formas Name <email> e mailto: e, ao colar, ignora palavras que não são endereços.
teamRolesAs quatro funções com seus rótulos e descrições, em ordem do maior ao menor acesso.
PropTipoPadrão
members{ id, name, email, role, avatarUrl?, lastActiveAt?, joinedAt? }. lastActiveAt null mostra Not yet active.
TeamMember[]–
invites{ id, email, role, sentAt, expiresAt, link? }. Os horários aceitam um Date, uma string ISO ou milissegundos. link habilita Copy invite link.
TeamInvite[][]
currentUserIdO membro conectado. Sua função decide quais ações aparecem.
string–
seatsVagas do plano. Os membros e os convites não expirados usam uma cada. Omita para vagas ilimitadas.
number–
domainMostra uma linha que permite que pessoas com um e-mail corporativo verificado entrem sem convite.
{ name, verified, autoJoin, role? }–
resendCooldownSegundos após o envio de um convite antes de ele poder ser enviado de novo.
number60
pageSizeQuantos membros aparecem antes de Show more.
number50
onInviteAdiciona a caixa de convite. Retorne mensagens por e-mail para os endereços que o seu servidor recusou; eles permanecem como chips com a mensagem. Lance um erro para manter todos os chips e mostrar o erro no botão.
({ emails, role, note }) => void | { [email]: message } | Promise–
onRoleChangeAdiciona o menu de função. A linha mostra um spinner até ser resolvido, e um toast se lançar um erro.
(memberId, role) => void | Promise–
onRemoveAdiciona Remove from team, e Leave team na sua própria linha. Se lançar um erro, o diálogo permanece aberto com a mensagem.
(memberId) => void | Promise–
onRestoreAdiciona Undo ao toast após uma remoção, com o membro como era.
(member) => void | Promise–
onTransferOwnershipAdiciona Transfer ownership para os proprietários, confirmada digitando o e-mail do novo proprietário.
(memberId) => void | Promise–
onResendAdiciona Resend. Atualize sentAt e expiresAt para que o intervalo de espera comece e o link seja renovado.
(inviteId) => void | Promise–
onRevokeAdiciona Revoke invite.
(inviteId) => void | Promise–
onAutoJoinChangeFaz o switch de domínio funcionar. Ele sacode e permanece no lugar se isto lançar um erro.
(enabled) => void | Promise–
onUpgradeAdiciona um botão Add seats, que vira a ação principal quando todas as vagas estão ocupadas.
() => void–
upgradeHrefFaz de Add seats um link.
string–
classNameClasses para o wrapper, uma coluna de grupos.
string–

parseEmailList

Retorna { emails, skipped }.

PropTipoPadrão
textQualquer texto com endereços, separados por vírgulas, pontos e vírgulas, espaços, tabulações ou quebras de linha.
string–
options.pasteIgnora palavras sem @ quando há pelo menos um endereço e as conta em skipped.
booleanfalse
TeclaAção
EnterNa caixa de convite, transforma o que você digitou em um chip. Pressione de novo em uma caixa vazia para enviar.
,Também transforma o que você digitou em um chip, assim como o espaço e o ponto e vírgula.
BackspaceEm uma caixa de convite vazia, vai para o último chip. Backspace ou Delete ali o remove e vai para o anterior.
⌘EnterEnvia os convites da caixa de endereços ou da nota. Ctrl+Enter no Windows e no Linux.
EscapeLimpa a busca de membros, ou fecha um menu ou diálogo aberto.
  • As linhas de membros são itens de lista nomeados pela pessoa. Os menus de função são nomeados como "Admin, alterar função de Ana Lima" e o botão de mais opções como "Mais ações para Ana Lima".
  • Os chips que não podem ser enviados trazem o motivo em seu nome, e a lista de problemas sob a caixa fica associada ao input como sua descrição.
  • Adicionar endereços, enviar, mudanças de função e remoções são anunciados de forma polite em uma única região live. Os erros nos diálogos são anunciados como alertas.
  • As ações desabilitadas se explicam: o menu de função e o menu de mais opções dizem por que o único proprietário não pode mudar sua função nem sair, e Resend continua focalizável durante o intervalo de espera, com o tempo restante em seu nome.
  • Após uma remoção, o foco vai para as ações do próximo membro, ou do anterior no fim da lista. Revogar um convite faz o mesmo nos convites.
  • O medidor de vagas é um meter com seu rótulo e a contagem em texto. Com movimento reduzido, ele muda sem deslizar.

Construído com

Os componentes gratuitos do HextaUI de que Team é feito. Cada um é instalado separadamente.

Código

3 arquivos, adicionados a components/blocks/team.