Skip to contentVibraUI
Part of the People dashboardinstalls at /people/invitations

Pending invitations

Seats that have been offered: what is still waiting with the time each link has left, resend and revoke on every one of them, and a table of everything already settled.

Open the live page

The page is a server component inside AppShell, with the People dashboard's nav.ts describing the navigation as plain data. db.invitations is the whole page — who was offered a seat, what it grants, when the link stops working — and the sender, which the row stores as a db.members id, is resolved to a name rather than shown as an id. The counts under the title are those same rows grouped by the status they already carry. What is waiting shows its expiry as a RelativeTime, because that is the question being asked: how long is left, not what the date is. actions.ts holds both mutations, a 'use server' file whose every export is an async function returning Result: resendInvitation re-dates the row from REFERENCE_DATE and puts it back in the waiting list, revokeInvitation kills the link but keeps the row so the trail still reads, and both refuse an invitation someone has already accepted. Only an expired invitation is offered a resend in the settled table — an accepted seat has nothing to send and a revoked link was killed on purpose. Composes AppShell, PageHeader, FormSection, EmptyState, RelativeTime, Badge, AsyncButton, ConfirmDialog, SimpleTable and StatusBadge.

Preview

Install

npx shadcn@latest add @vibra/people-invitations

Needs the @vibra registry in your components.json — set it up once.

Source

app/people/invitations/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"

import { resendInvitation, revokeInvitation, signOut } from "./actions"
import { InvitationsView } from "./components/invitations-view"
import {
  currentUser,
  invitations,
  shellNotifications,
  summary,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/people/nav"

/**
 * Seats that have been offered. The page is a server component inside the
 * shell: it reads the invitations through `db`, resolves each sender to a
 * name, and hands the lists the rows plus the two server actions that move a
 * row between them.
 */
export default function TeamInvitationsPage() {
  const counts = summary()

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.invitations}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Invitations"
        description="Who has been offered a seat, what it grants them, and how long each link has left."
        meta={`${counts.pending} waiting · ${counts.accepted} accepted · ${counts.expired} expired · ${counts.revoked} revoked · dates in UTC`}
      />

      <InvitationsView
        rows={invitations()}
        now={REFERENCE_DATE}
        onResend={resendInvitation}
        onRevoke={revokeInvitation}
      />
    </AppShell>
  )
}
app/people/invitations/data.ts
/**
 * What this page reads, and the two things it changes.
 *
 * `db.invitations` is the page: who has been offered a seat, what it grants,
 * who sent it and when the link stops working. The sender is a `db.members`
 * id, so it is resolved to a name here rather than shown as a row id. Nothing
 * is generated — the counts under the title are the same rows grouped by the
 * status they already carry.
 *
 * `resendInvitation` and `revokeInvitation` are the mutations, server actions
 * returning `Result`. A resend re-dates the invitation from `REFERENCE_DATE`,
 * which is what "now" means everywhere in this registry, so the expiry the
 * page then shows is measured from the same instant the rest of the data is.
 */
import { getInitials } from "@/lib/format"
import { db, type Invitation, type Member } from "@/lib/sample-data"

export type InvitationRow = {
  id: string
  email: string
  role: Invitation["role"]
  status: Invitation["status"]
  sentAt: Date
  expiresAt: Date
  invitedBy: string
}

// Read per call, never held at module scope: a sender renamed since the server
// started is the name on the next render.
const memberNames = () => new Map(db.members.all().map((member) => [member.id, member.name]))

/** One db row as this page reads it, with its sender named. */
export function invitationRow(row: Invitation, names = memberNames()): InvitationRow {
  return {
    id: row.id,
    email: row.email,
    role: row.role,
    status: row.status,
    sentAt: row.sentAt,
    expiresAt: row.expiresAt,
    invitedBy: names.get(row.invitedBy) ?? "A teammate",
  }
}

/** Every invitation, the ones still waiting first, then newest sent. */
export function invitations(): InvitationRow[] {
  const names = memberNames()
  return db.invitations
    .all()
    .map((row) => invitationRow(row, names))
    .sort((a, b) => {
      const waiting = Number(b.status === "pending") - Number(a.status === "pending")
      return waiting !== 0 ? waiting : b.sentAt.getTime() - a.sentAt.getTime()
    })
}

/** The same rows grouped by the status they carry. */
export function summary(): Record<Invitation["status"], number> {
  const counts: Record<Invitation["status"], number> = {
    pending: 0,
    accepted: 0,
    expired: 0,
    revoked: 0,
  }
  for (const row of db.invitations.all()) counts[row.status] += 1
  return counts
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/people/invitations/actions.ts
"use server"

/**
 * Everything this page changes. A `"use server"` file rather than a directive
 * inside each function: an inline one is only legal in a module the bundler
 * knows is server-only, and `data.ts` is imported for its types by components
 * that are not. Every export here is an async function returning `Result`.
 */
import { mockAuthAdapter } from "@/lib/auth-adapter"
import {
  db,
  REFERENCE_DATE,
  type Invitation,
  type Result,
} from "@/lib/sample-data"

import { invitationRow, type InvitationRow } from "./data"

const DAY_MS = 86_400_000

/** How long a fresh invitation stays good for, in days. */
const INVITE_WINDOW = 14

/** An invitation someone has already accepted is done; there is nothing to act on. */
function actionable(row: Invitation | undefined): Result<Invitation> | undefined {
  if (!row) {
    return { ok: false, error: { code: "not_found", message: "That invitation has gone." } }
  }
  if (row.status === "accepted") {
    return {
      ok: false,
      error: {
        code: "already_accepted",
        message: `${row.email} has already accepted; there is nothing left to send.`,
      },
    }
  }
  return undefined
}

/** Send the link again, and start the fourteen days over from REFERENCE_DATE. */
export async function resendInvitation(id: string): Promise<Result<InvitationRow>> {
  const row = db.invitations.all().find((item) => item.id === id)
  const refused = actionable(row)
  if (refused) return refused

  const saved = await db.invitations.update(id, {
    sentAt: REFERENCE_DATE,
    expiresAt: new Date(REFERENCE_DATE.getTime() + INVITE_WINDOW * DAY_MS),
    status: "pending",
  })
  if (!saved.ok) return saved
  return { ok: true, data: invitationRow(saved.data) }
}

/** Kill the link. The row stays, so the audit trail still reads. */
export async function revokeInvitation(id: string): Promise<Result<InvitationRow>> {
  const row = db.invitations.all().find((item) => item.id === id)
  const refused = actionable(row)
  if (refused) return refused
  if (row?.status === "revoked") {
    return {
      ok: false,
      error: { code: "already_revoked", message: "That link was already killed." },
    }
  }

  const saved = await db.invitations.update(id, { status: "revoked" })
  if (!saved.ok) return saved
  return { ok: true, data: invitationRow(saved.data) }
}

/** Signing out is the shell's one action, and a server action for the same reason. */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/people/invitations/components/invitations-view.tsx
"use client"

import * as React from "react"

import type { InvitationRow } from "../data"
import { PendingInvitations } from "./pending-invitations"
import { ResolvedInvitations } from "./resolved-invitations"

export type InvitationChanged =
  | { ok: true; data: InvitationRow }
  | { ok: false; error: { code: string; message: string; field?: string } }

/**
 * Both lists over one set of rows: a resend moves an invitation back into the
 * waiting list and a revoke moves it out, because the page is showing the same
 * rows the actions just changed. Whatever comes back that is not a change is
 * said out loud rather than swallowed.
 */
export function InvitationsView({
  rows,
  now,
  onResend,
  onRevoke,
}: {
  rows: InvitationRow[]
  /** What the expiry countdown counts from; the page passes REFERENCE_DATE. */
  now: Date
  onResend: (id: string) => Promise<InvitationChanged>
  onRevoke: (id: string) => Promise<InvitationChanged>
}) {
  const [items, setItems] = React.useState(rows)
  const [error, setError] = React.useState<string | null>(null)
  const [note, setNote] = React.useState<string | null>(null)

  function apply(result: InvitationChanged, said: string) {
    if (!result.ok) {
      setNote(null)
      setError(result.error.message)
      return
    }
    setItems((current) => current.map((item) => (item.id === result.data.id ? result.data : item)))
    setError(null)
    setNote(said)
  }

  async function resend(row: InvitationRow) {
    apply(await onResend(row.id), `The invitation to ${row.email} was sent again.`)
  }

  async function revoke(row: InvitationRow) {
    apply(await onRevoke(row.id), `The invitation to ${row.email} no longer works.`)
  }

  const pending = items.filter((item) => item.status === "pending")
  const settled = items.filter((item) => item.status !== "pending")

  return (
    <>
      {error ? (
        <p role="alert" className="text-sm text-danger">
          {error}
        </p>
      ) : null}
      {note ? (
        <p role="status" className="text-sm text-muted-foreground">
          {note}
        </p>
      ) : null}

      <PendingInvitations rows={pending} now={now} onResend={resend} onRevoke={revoke} />
      <ResolvedInvitations rows={settled} onResend={resend} />
    </>
  )
}
app/people/invitations/components/pending-invitations.tsx
"use client"

import { MailCheckIcon, XIcon } from "lucide-react"

import { AsyncButton } from "@/components/ui/async-button"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { EmptyState } from "@/components/ui/empty-state"
import { FormSection } from "@/components/ui/form-section"
import { RelativeTime } from "@/components/ui/relative-time"

import type { InvitationRow } from "../data"

/**
 * The invitations that can still be accepted, each with the two things anyone
 * ever wants to do about one. The expiry is relative because that is the
 * question being asked: how long is left, not what the date is.
 */
export function PendingInvitations({
  rows,
  now,
  onResend,
  onRevoke,
}: {
  rows: InvitationRow[]
  /** What the countdown counts from — REFERENCE_DATE, the point the expiries were generated from. */
  now: Date
  onResend: (row: InvitationRow) => Promise<void>
  onRevoke: (row: InvitationRow) => Promise<void>
}) {
  return (
    <FormSection
      title="Awaiting a reply"
      description="Seats that have been offered and not yet taken. Nothing is billed until one is accepted."
    >
      {rows.length === 0 ? (
        <EmptyState
          variant="dashed"
          size="sm"
          icon={<MailCheckIcon />}
          title="Nothing outstanding"
          description="Every invitation has been accepted, has expired, or was withdrawn."
        />
      ) : (
        <ul className="flex flex-col gap-2">
          {rows.map((row) => (
            <li
              key={row.id}
              className="flex flex-wrap items-center justify-between gap-3 panel p-3"
            >
              <div className="flex min-w-0 flex-col gap-1">
                <div className="flex flex-wrap items-center gap-2">
                  <span className="truncate text-sm font-medium">{row.email}</span>
                  <Badge variant="outline" className="capitalize">
                    {row.role}
                  </Badge>
                </div>
                <p className="text-xs text-muted-foreground">
                  Sent by {row.invitedBy} · expires{" "}
                  <RelativeTime date={row.expiresAt} now={now} format="long" />
                </p>
              </div>

              <div className="flex shrink-0 items-center gap-2">
                <AsyncButton
                  size="sm"
                  variant="outline"
                  aria-label={`Resend the invitation to ${row.email}`}
                  onClick={() => onResend(row)}
                >
                  Resend
                </AsyncButton>
                <ConfirmDialog
                  variant="destructive"
                  title={`Revoke the invitation to ${row.email}?`}
                  description="The link stops working at once. Sending another one is the only way back."
                  confirmText="Revoke"
                  onConfirm={() => onRevoke(row)}
                  trigger={
                    <Button
                      variant="ghost"
                      size="icon-sm"
                      aria-label={`Revoke the invitation to ${row.email}`}
                    >
                      <XIcon />
                    </Button>
                  }
                />
              </div>
            </li>
          ))}
        </ul>
      )}
    </FormSection>
  )
}
app/people/invitations/components/resolved-invitations.tsx
"use client"

import { AsyncButton } from "@/components/ui/async-button"
import { FormSection } from "@/components/ui/form-section"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"

import type { InvitationRow } from "../data"

// An invitation is sent at an instant, so formatting it in the reader's own
// zone would move some of these to the day before west of UTC. The page's meta
// line says the dates are UTC; this is what makes that true.
const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })

// None of the three settled states is in StatusBadge's own vocabulary.
const STATUS_MAP = { accepted: "success", expired: "neutral", revoked: "danger" } as const

/** Everything that has already been settled, and the ones worth sending again. */
export function ResolvedInvitations({
  rows,
  onResend,
}: {
  rows: InvitationRow[]
  onResend: (row: InvitationRow) => Promise<void>
}) {
  const columns: SimpleTableColumn<InvitationRow>[] = [
    { key: "email", header: "Invited" },
    {
      key: "role",
      header: "Access",
      cell: (row) => <span className="text-muted-foreground capitalize">{row.role}</span>,
    },
    {
      key: "status",
      header: "Outcome",
      cell: (row) => <StatusBadge status={row.status} map={STATUS_MAP} />,
    },
    { key: "invitedBy", header: "Sent by" },
    {
      key: "sentAt",
      header: "Sent",
      align: "right",
      cell: (row) => (
        <span className="text-muted-foreground tabular-nums">{DAY.format(row.sentAt)}</span>
      ),
    },
    {
      key: "actions",
      header: <span className="sr-only">Actions</span>,
      align: "right",
      width: "110px",
      cell: (row) =>
        // An accepted seat has nothing to send again, and a revoked link was
        // killed on purpose; an expired one is the only one worth offering.
        row.status === "expired" ? (
          <AsyncButton
            size="sm"
            variant="outline"
            aria-label={`Resend the invitation to ${row.email}`}
            onClick={() => onResend(row)}
          >
            Resend
          </AsyncButton>
        ) : null,
    },
  ]

  return (
    <FormSection
      title="Settled"
      description="Invitations that were accepted, ran out, or were withdrawn. An expired one can be offered again."
    >
      <SimpleTable size="sm" columns={columns} rows={rows} rowKey="id" hoverable />
    </FormSection>
  )
}