Particular.Tech
Blocksblock

Members

Organization members with roles, pending invitations with shareable links, and an invite dialog.

Preview

Open full screen

Installation

npx shadcn@latest add @particular/members

Usage

members-demo.tsx
"use client"

import * as React from "react"

import { Members, type Member, type PendingInvitation } from "@/components/members"
import { minutesAgo } from "@/registry/particular/examples/demo-data"
import { toast } from "@/components/ui/toast"

function wait(ms: number) {
  return new Promise<void>((resolve) => setTimeout(resolve, ms))
}

const roles = [
  { value: "owner", label: "Owner", description: "Full control, including deleting the organization." },
  { value: "admin", label: "Admin", description: "Manage members, invitations, and API keys." },
  { value: "member", label: "Member", description: "Use the organization's resources and read its API keys." },
]

export function Demo() {
  const [members, setMembers] = React.useState<Member[]>([
    { id: "m1", name: "Ada Lovelace", email: "ada@particular.tech", role: "owner", joinedAt: minutesAgo(60 * 24 * 90) },
    { id: "m2", name: "Charles Babbage", email: "charles@particular.tech", role: "admin", joinedAt: minutesAgo(60 * 24 * 30) },
    { id: "m3", name: "Mary Somerville", email: "mary@particular.tech", role: "member", joinedAt: minutesAgo(60 * 5) },
  ])
  const [invitations, setInvitations] = React.useState<PendingInvitation[]>([
    { id: "i1", email: "grace@example.com", role: "member", expiresAt: minutesAgo(-60 * 24 * 2), url: "https://auth.particular.tech/invitations/i1" },
  ])

  return (
    <div className="mx-auto w-full max-w-3xl p-6">
      <Members
        members={members}
        invitations={invitations}
        roles={roles}
        currentMemberId="m1"
        canManage
        onInvite={async (email, role) => {
          await wait(700)
          if (email.endsWith("@blocked.dev")) throw new Error("This email address cannot be invited.")
          const id = `i${invitations.length + 2}`
          const url = `https://auth.particular.tech/invitations/${id}`
          setInvitations((current) => [...current, { id, email, role, expiresAt: minutesAgo(-60 * 24 * 2), url }])
          return { url }
        }}
        onChangeRole={async (memberId, role) => {
          await wait(500)
          setMembers((current) => current.map((member) => (member.id === memberId ? { ...member, role } : member)))
          toast.add({ title: "Role updated", type: "success" })
        }}
        onRemove={async (memberId) => {
          await wait(600)
          setMembers((current) => current.filter((member) => member.id !== memberId))
        }}
        onCancelInvitation={async (invitationId) => {
          await wait(500)
          setInvitations((current) => current.filter((invitation) => invitation.id !== invitationId))
        }}
      />
    </div>
  )
}

Source

blocks/members/members.tsx
"use client"

import * as React from "react"
import { MailIcon, UserPlusIcon } from "lucide-react"

import { Alert, AlertDescription } from "@/components/ui/alert"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { CopyButton } from "@/components/ui/copy-button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { RelativeTime } from "@/components/ui/relative-time"
import { Spinner } from "@/components/ui/spinner"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"

export type Member = {
  id: string
  name: string
  email: string
  avatarUrl?: string | null
  role: string
  joinedAt: Date | string
}

export type PendingInvitation = {
  id: string
  email: string
  role: string
  expiresAt: Date | string
  /** Link the invitee opens to accept. */
  url: string
}

export type MemberRole = { value: string; label: string; description?: string }

export type MembersProps = {
  members: Member[]
  invitations: PendingInvitation[]
  /** Roles a member can be given, in display order. */
  roles: MemberRole[]
  /** The signed-in member: marked "You" and never offered for removal here. */
  currentMemberId: string
  /** Show invite, role, remove, and cancel controls. */
  canManage: boolean
  /** Create an invitation and return the link to share with the invitee. */
  onInvite: (email: string, role: string) => Promise<{ url: string }>
  onChangeRole: (memberId: string, role: string) => Promise<void>
  onRemove: (memberId: string) => Promise<void>
  onCancelInvitation: (invitationId: string) => Promise<void>
}

function initials(name: string) {
  return name
    .split(/\s+/)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase() ?? "")
    .join("")
}

function messageOf(error: unknown) {
  return error instanceof Error ? error.message : "Something went wrong. Try again."
}

function InviteDialog({
  open,
  onOpenChange,
  roles,
  onInvite,
}: {
  open: boolean
  onOpenChange: (open: boolean) => void
  roles: MemberRole[]
  onInvite: MembersProps["onInvite"]
}) {
  const [url, setUrl] = React.useState<string | null>(null)
  const [pending, setPending] = React.useState(false)
  const [error, setError] = React.useState<string | null>(null)
  const [role, setRole] = React.useState(roles[roles.length - 1]?.value ?? "")

  const close = (next: boolean) => {
    if (next) return onOpenChange(true)
    if (pending) return
    setUrl(null)
    setError(null)
    onOpenChange(false)
  }

  const submit = async (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const email = String(new FormData(event.currentTarget).get("email") ?? "").trim()
    if (!email) return setError("Enter the email address of the person to invite.")
    setPending(true)
    setError(null)
    try {
      setUrl((await onInvite(email, role)).url)
    } catch (caught) {
      setError(messageOf(caught))
    } finally {
      setPending(false)
    }
  }

  const description = roles.find((option) => option.value === role)?.description

  return (
    <Dialog open={open} onOpenChange={close}>
      <DialogContent className="sm:max-w-md">
        {url ? (
          <>
            <DialogHeader>
              <DialogTitle>Share the invitation link</DialogTitle>
              <DialogDescription>Send this link to the invitee. They join after signing in with the invited email address.</DialogDescription>
            </DialogHeader>
            <InputGroup>
              <InputGroupInput readOnly value={url} aria-label="Invitation link" className="font-mono text-xs" />
              <InputGroupAddon align="inline-end">
                <CopyButton value={url} label="Copy invitation link" size="icon-xs" />
              </InputGroupAddon>
            </InputGroup>
            <DialogFooter>
              <Button onClick={() => close(false)}>Done</Button>
            </DialogFooter>
          </>
        ) : (
          <form onSubmit={submit} className="grid gap-4">
            <DialogHeader>
              <DialogTitle>Invite a member</DialogTitle>
              <DialogDescription>You get a link to share. The invitation expires if it is not accepted.</DialogDescription>
            </DialogHeader>
            <Field data-invalid={error ? true : undefined}>
              <FieldLabel htmlFor="invite-email">Email</FieldLabel>
              <Input
                id="invite-email"
                name="email"
                type="email"
                placeholder="grace@example.com"
                autoComplete="off"
                autoFocus
                aria-invalid={error ? true : undefined}
              />
              {error && <FieldError>{error}</FieldError>}
            </Field>
            <Field>
              <FieldLabel htmlFor="invite-role">Role</FieldLabel>
              <NativeSelect id="invite-role" value={role} onChange={(event) => setRole(event.target.value)}>
                {roles.map((option) => (
                  <NativeSelectOption key={option.value} value={option.value}>
                    {option.label}
                  </NativeSelectOption>
                ))}
              </NativeSelect>
              {description && <FieldDescription>{description}</FieldDescription>}
            </Field>
            <DialogFooter>
              <Button type="button" variant="outline" disabled={pending} onClick={() => close(false)}>
                Cancel
              </Button>
              <Button type="submit" disabled={pending}>
                {pending && <Spinner data-icon="inline-start" />}
                Create invitation
              </Button>
            </DialogFooter>
          </form>
        )}
      </DialogContent>
    </Dialog>
  )
}

function ConfirmDialog({
  title,
  description,
  action,
  onConfirm,
  onClose,
}: {
  title: string | null
  description: string
  action: string
  onConfirm: () => Promise<void>
  onClose: () => void
}) {
  const [pending, setPending] = React.useState(false)
  const [error, setError] = React.useState<string | null>(null)

  const confirm = async () => {
    setPending(true)
    setError(null)
    try {
      await onConfirm()
      onClose()
    } catch (caught) {
      setError(messageOf(caught))
    } finally {
      setPending(false)
    }
  }

  return (
    <AlertDialog
      open={title !== null}
      onOpenChange={(open) => {
        if (pending || open) return
        setError(null)
        onClose()
      }}
    >
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>{title}</AlertDialogTitle>
          <AlertDialogDescription>{description}</AlertDialogDescription>
        </AlertDialogHeader>
        {error && (
          <Alert variant="destructive">
            <AlertDescription>{error}</AlertDescription>
          </Alert>
        )}
        <AlertDialogFooter>
          <AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
          <AlertDialogAction
            variant="destructive"
            disabled={pending}
            onClick={(event) => {
              event.preventDefault()
              void confirm()
            }}
          >
            {pending && <Spinner data-icon="inline-start" />}
            {action}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

function RoleCell({ member, roles, editable, onChangeRole }: { member: Member; roles: MemberRole[]; editable: boolean; onChangeRole: MembersProps["onChangeRole"] }) {
  const [pending, setPending] = React.useState(false)
  const [error, setError] = React.useState<string | null>(null)
  const label = roles.find((role) => role.value === member.role)?.label ?? member.role

  if (!editable) return <Badge variant="secondary">{label}</Badge>

  return (
    <div className="flex flex-col gap-1">
      <NativeSelect
        size="sm"
        className="w-24 sm:w-28"
        value={member.role}
        disabled={pending}
        aria-label={`Role of ${member.name}`}
        onChange={async (event) => {
          setPending(true)
          setError(null)
          try {
            await onChangeRole(member.id, event.target.value)
          } catch (caught) {
            setError(messageOf(caught))
          } finally {
            setPending(false)
          }
        }}
      >
        {roles.map((role) => (
          <NativeSelectOption key={role.value} value={role.value}>
            {role.label}
          </NativeSelectOption>
        ))}
      </NativeSelect>
      {error && <span className="text-xs text-destructive">{error}</span>}
    </div>
  )
}

function Members({ members, invitations, roles, currentMemberId, canManage, onInvite, onChangeRole, onRemove, onCancelInvitation }: MembersProps) {
  const [inviteOpen, setInviteOpen] = React.useState(false)
  const [removing, setRemoving] = React.useState<Member | null>(null)
  const [cancelling, setCancelling] = React.useState<PendingInvitation | null>(null)
  const roleLabel = (value: string) => roles.find((role) => role.value === value)?.label ?? value

  return (
    <div className="flex flex-col gap-6">
      <div className="flex items-center justify-between gap-4">
        <div>
          <h2 className="font-heading text-base font-semibold">Members</h2>
          <p className="text-sm text-muted-foreground">
            {members.length} {members.length === 1 ? "member" : "members"}
          </p>
        </div>
        {canManage && (
          <Button onClick={() => setInviteOpen(true)}>
            <UserPlusIcon data-icon="inline-start" />
            Invite member
          </Button>
        )}
      </div>

      <div className="overflow-hidden rounded-lg border bg-card">
        <Table>
          <TableHeader className="bg-muted/40">
            <TableRow>
              <TableHead>Member</TableHead>
              <TableHead>Role</TableHead>
              <TableHead className="hidden sm:table-cell">Joined</TableHead>
              {canManage && (
                <TableHead className="w-0">
                  <span className="sr-only">Actions</span>
                </TableHead>
              )}
            </TableRow>
          </TableHeader>
          <TableBody>
            {members.map((member) => {
              const isSelf = member.id === currentMemberId
              return (
                <TableRow key={member.id}>
                  <TableCell className="w-full max-w-0">
                    <div className="flex items-center gap-3">
                      <Avatar className="size-8">
                        {member.avatarUrl && <AvatarImage src={member.avatarUrl} alt="" />}
                        <AvatarFallback className="text-xs">{initials(member.name)}</AvatarFallback>
                      </Avatar>
                      <div className="min-w-0">
                        <div className="flex items-center gap-2 font-medium">
                          <span className="truncate">{member.name}</span>
                          {isSelf && <Badge variant="outline">You</Badge>}
                        </div>
                        <div className="truncate text-sm text-muted-foreground">{member.email}</div>
                      </div>
                    </div>
                  </TableCell>
                  <TableCell>
                    <RoleCell member={member} roles={roles} editable={canManage && !isSelf} onChangeRole={onChangeRole} />
                  </TableCell>
                  <TableCell className="hidden text-muted-foreground sm:table-cell">
                    <RelativeTime date={member.joinedAt} />
                  </TableCell>
                  {canManage && (
                    <TableCell>
                      {!isSelf && (
                        <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => setRemoving(member)}>
                          Remove
                        </Button>
                      )}
                    </TableCell>
                  )}
                </TableRow>
              )
            })}
          </TableBody>
        </Table>
      </div>

      {invitations.length > 0 && (
        <div className="flex flex-col gap-3">
          <h3 className="text-sm font-medium">Pending invitations</h3>
          <div className="overflow-hidden rounded-lg border bg-card">
            <Table>
              <TableBody>
                {invitations.map((invitation) => (
                  <TableRow key={invitation.id}>
                    <TableCell className="w-full max-w-0">
                      <div className="flex items-center gap-3">
                        <span className="flex size-8 items-center justify-center rounded-full border bg-muted/40 text-muted-foreground">
                          <MailIcon className="size-4" />
                        </span>
                        <div className="min-w-0">
                          <div className="truncate font-medium">{invitation.email}</div>
                          <div className="text-sm text-muted-foreground">
                            {roleLabel(invitation.role)} · expires <RelativeTime date={invitation.expiresAt} />
                          </div>
                        </div>
                      </div>
                    </TableCell>
                    {canManage && (
                      <TableCell className="w-0">
                        <div className="flex items-center gap-1">
                          <CopyButton value={invitation.url} label={`Copy invitation link for ${invitation.email}`} />
                          <Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={() => setCancelling(invitation)}>
                            Cancel
                          </Button>
                        </div>
                      </TableCell>
                    )}
                  </TableRow>
                ))}
              </TableBody>
            </Table>
          </div>
        </div>
      )}

      <InviteDialog open={inviteOpen} onOpenChange={setInviteOpen} roles={roles} onInvite={onInvite} />
      <ConfirmDialog
        title={removing ? `Remove ${removing.name}?` : null}
        description="They lose access to this organization and everything it owns."
        action="Remove member"
        onConfirm={async () => {
          if (removing) await onRemove(removing.id)
        }}
        onClose={() => setRemoving(null)}
      />
      <ConfirmDialog
        title={cancelling ? `Cancel the invitation for ${cancelling.email}?` : null}
        description="The link stops working."
        action="Cancel invitation"
        onConfirm={async () => {
          if (cancelling) await onCancelInvitation(cancelling.id)
        }}
        onClose={() => setCancelling(null)}
      />
    </div>
  )
}

export { Members }