Blocksblock
Members
Organization members with roles, pending invitations with shareable links, and an invite dialog.
Preview
Open full screenInstallation
npx shadcn@latest add @particular/membersnpm dependencies
- lucide-react@^1.51.0
Installs these registry items
Usage
"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
"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 }