BlocksUpdated September 15, 2026

Workspace invitations

A source-copy invitation list with consumer-owned recipients, roles, status, expiry, permissions, and actions.

Present invitations without owning invitation deliveryLink to section

WorkspaceInvitationsPanel renders a named invitation section with recipient, role, status, sent/expiry timestamps and optional actions. Your application supplies every invitation and owns what each action is allowed to do.

Presentation only

This Block does not create invitations, send email, generate or validate tokens, accept memberships, revoke access, authorize actions or persist state. Those behaviors remain behind your application's trusted boundaries.

Canonical identityLink to section

  • Category: Account & workspace, slug account.
  • Block: workspace-invitations, export WorkspaceInvitationsPanel.
  • Canonical source: apps/marketing/content/blocks/account/workspace-invitations/.
  • Complete entry point: index.tsx.

There is no Blocks npm package, Registry installer or CLI in this flow. The canonical file is presentation source that you copy into your application.

Copy sourceLink to section

Configure the public UI package and tokens, then create src/components/blocks/workspace-invitations/index.tsx in your application. Open the source below and use its copy button, or select the code manually. This is the complete canonical file, not a duplicated implementation.

View and copy the complete source
src/components/blocks/workspace-invitations/index.tsx
import * as React from "react";

export interface WorkspaceInvitation {
  /** Stable identity, unique within this panel; used as the React key. */
  id: string;
  recipient: string;
  secondaryText?: string;
  /** Display text only. The application owns role validation and authorization. */
  role: string;
  /** Consumer-defined invitation state such as Pending, Accepted, or Expired. */
  status: string;
  sentAt: string;
  sentAtLabel: string;
  expiresAt?: string;
  expiresAtLabel?: string;
  actions?: React.ReactNode;
}

export interface WorkspaceInvitationsPanelProps {
  /** Stable HTML id, unique across the consuming page. */
  id: string;
  heading: string;
  description?: string;
  invitations: readonly WorkspaceInvitation[];
  actions?: React.ReactNode;
  emptyTitle?: string;
  emptyDescription?: string;
  className?: string;
}

function InvitationActions({ children }: { children?: React.ReactNode }) {
  if (!React.Children.toArray(children).some((child) => child !== "")) {
    return null;
  }

  return (
    <div
      data-slot="workspace-invitations-actions"
      className="flex min-w-0 flex-wrap items-center gap-2 [&>*]:max-w-full [&>*]:whitespace-normal [&>*]:break-words"
    >
      {children}
    </div>
  );
}

export function WorkspaceInvitationsPanel({
  id,
  heading,
  description,
  invitations,
  actions,
  emptyTitle = "No invitations to display",
  emptyDescription,
  className,
}: WorkspaceInvitationsPanelProps) {
  const headingId = `${id}-heading`;
  const descriptionId = description ? `${id}-description` : undefined;

  return (
    <section
      id={id}
      aria-labelledby={headingId}
      aria-describedby={descriptionId}
      data-slot="workspace-invitations-panel"
      className={[
        "min-w-0 rounded-xl border border-border bg-card text-card-foreground",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
    >
      <div className="flex min-w-0 flex-col gap-4 border-b border-border p-4 sm:flex-row sm:items-start sm:justify-between sm:p-6">
        <div className="min-w-0 space-y-1">
          <h2 id={headingId} className="break-words text-base font-semibold">
            {heading}
          </h2>
          {description ? (
            <p
              id={descriptionId}
              className="break-words text-sm text-muted-foreground"
            >
              {description}
            </p>
          ) : null}
        </div>
        <InvitationActions>{actions}</InvitationActions>
      </div>

      {invitations.length > 0 ? (
        <ul
          role="list"
          aria-labelledby={headingId}
          className="divide-y divide-border"
        >
          {invitations.map((invitation) => (
            <li
              key={invitation.id}
              data-slot="workspace-invitation"
              className="flex min-w-0 flex-col gap-4 p-4 sm:p-6 lg:flex-row lg:items-center lg:justify-between"
            >
              <div className="min-w-0 flex-1 space-y-3">
                <div className="min-w-0 space-y-1">
                  <h3 className="break-words text-sm font-medium">
                    {invitation.recipient}
                  </h3>
                  {invitation.secondaryText ? (
                    <p className="break-words text-sm text-muted-foreground">
                      {invitation.secondaryText}
                    </p>
                  ) : null}
                </div>

                <dl className="flex min-w-0 flex-wrap gap-x-6 gap-y-3">
                  <div className="min-w-0 space-y-1">
                    <dt className="text-xs text-muted-foreground">Role</dt>
                    <dd className="min-w-0 break-words text-sm">
                      {invitation.role}
                    </dd>
                  </div>
                  <div className="min-w-0 space-y-1">
                    <dt className="text-xs text-muted-foreground">Status</dt>
                    <dd className="min-w-0">
                      <span
                        data-slot="workspace-invitation-status"
                        className="inline-flex h-auto max-w-full items-center rounded-md border border-border px-2 py-0.5 text-xs font-medium whitespace-normal break-words"
                      >
                        {invitation.status}
                      </span>
                    </dd>
                  </div>
                  <div className="min-w-0 space-y-1">
                    <dt className="text-xs text-muted-foreground">Sent</dt>
                    <dd className="min-w-0 break-words text-sm">
                      <time dateTime={invitation.sentAt}>
                        {invitation.sentAtLabel}
                      </time>
                    </dd>
                  </div>
                  {invitation.expiresAt && invitation.expiresAtLabel ? (
                    <div className="min-w-0 space-y-1">
                      <dt className="text-xs text-muted-foreground">Expires</dt>
                      <dd className="min-w-0 break-words text-sm">
                        <time dateTime={invitation.expiresAt}>
                          {invitation.expiresAtLabel}
                        </time>
                      </dd>
                    </div>
                  ) : null}
                </dl>
              </div>

              <InvitationActions>{invitation.actions}</InvitationActions>
            </li>
          ))}
        </ul>
      ) : (
        <div
          data-slot="workspace-invitations-empty"
          className="min-w-0 space-y-1 p-6 text-center sm:p-8"
        >
          <p className="break-words text-sm font-medium">{emptyTitle}</p>
          {emptyDescription ? (
            <p className="break-words text-sm text-muted-foreground">
              {emptyDescription}
            </p>
          ) : null}
        </div>
      )}
    </section>
  );
}

The documentation includes this file from canonical source at build time. Your application copy does not receive automatic updates. Connect your own invitation service and authorization, then validate it in your application. Return to the Blocks catalog to choose another pattern.

Install by copying sourceLink to section

  1. Copy the complete workspace-invitations directory into an application-owned path.
  2. Configure React, @pycolors/ui, tokens and semantic styles through the UI installation guide.
  3. Supply validated invitation data and consumer-owned actions.
  4. Run lint, type-check, tests and build in the consuming application.
src/components/workspace-invitations-list.tsx
"use client";

import { Button } from "@pycolors/ui";
import { WorkspaceInvitationsPanel } from "./blocks/workspace-invitations";

export function WorkspaceInvitationsList() {
  return (
    <WorkspaceInvitationsPanel
      id="workspace-invitations"
      heading="Pending invitations"
      description="Invitations supplied by the application."
      invitations={[
        {
          id: "invite-1",
          recipient: "sam@example.com",
          secondaryText: "Example recipient",
          role: "Editor",
          status: "Pending",
          sentAt: "2026-09-15T09:00:00Z",
          sentAtLabel: "15 September 2026",
          expiresAt: "2026-09-22T09:00:00Z",
          expiresAtLabel: "22 September 2026",
          actions: (
            <Button type="button" variant="outline">
              Revoke invitation
            </Button>
          ),
        },
      ]}
    />
  );
}

The example button performs no mutation by itself. Your application owns its handler, confirmation flow, permission check, network request and resulting state refresh.

Invitation truth remains yoursLink to section

id, heading and invitations are required. Give each panel a stable unique HTML id and each invitation a stable id unique within that list.

recipient, role, status, sentAt and sentAtLabel are display-ready consumer values. secondaryText, expiry values and actions are optional. The Block does not validate an address, map a role to permissions, derive invitation status, calculate expiry or decide whether an action should be shown.

Supply expiresAt and expiresAtLabel together when expiry should be visible. Use machine-readable timestamps for the dateTime values and your own localized human labels for presentation.

Top-level actions can expose an application-owned invitation entry point. Row actions can expose resend, revoke or view flows only when the current user is actually authorized. Omit unavailable actions rather than relying on a disabled button as a security boundary.

Empty, responsive and accessible statesLink to section

With invitations={[]}, the Block renders emptyTitle and optional emptyDescription. Loading, request failure and permission denial are distinct states that the consuming application should model explicitly.

The section is labelled by its heading. Invitations use a semantic list, role and status are visible text, and sent/expiry values use semantic <time> elements. Long addresses, roles, statuses and controls wrap rather than forcing page-level overflow.

The Block never moves focus, intercepts keys or announces status changes. Native controls and consumer components keep their own keyboard/focus semantics. Give repeated row controls distinct accessible names when their context would otherwise be ambiguous.

Ownership and security boundariesLink to section

Your backend owns invitation creation, token generation, email delivery, expiration, acceptance, revocation, membership mutation, replay protection and permission checks. The UI is not proof that an invitation is valid or that the current viewer may mutate it.

After copying, review future canonical changes deliberately and preserve local customizations. Validate long recipient text, empty results, expired/pending states, action permissions, narrow widths, both themes and keyboard behavior before production use.

No email delivery, token handling, authentication, RBAC implementation, database/schema, backend/network behavior, Registry or CLI capability is introduced by this Block or guide.