Skip to contentVibraUI
Shared pagesinstalls at /accept-invite

Accept invitation

Take a seat in a workspace: who invited you, into what and as what, a name and a password to set — and its own page, with its own heading, when the token has expired, been withdrawn, been used, or is not one we know.

Open the live page

db.invitations is read on the server for exactly one thing — defaultToken(), the token id the page opens on in a framed preview, read per request — and nothing about any row crosses that boundary: a block rendered inside a frame is mounted as a component and is never given searchParams of its own, so the island reads ?token= itself once mounted and asks lookupInvite, a server action, for that one token's row. The whole book is never serialized, so no invitee's email reaches HTML for a reader who was not sent it. A row's real state is the later of two facts — the status is what someone did to the invitation, the expiry is what time did to it — so a row that still says pending past its expiry reads as expired; "now" is REFERENCE_DATE, never the clock, so the page reads the same tomorrow. Every stamp is formatted with timeZone: "UTC". A token that cannot be taken is not a smaller version of this page: it gets the card's own h1, which is why the island renders the frame rather than the other way round, and there are four of those states, not one — plus a fifth, briefly, while the lookup is in flight. The email is the invitation's and travels as a hidden field: an invitation is *to* an address, and a form that let it be edited would be a way of claiming someone else's seat. Submitting goes through acceptInvite, which re-resolves the token against the store before it ever calls createAuthActions(mockAuthAdapter).signUp — a token that stopped being pending between the page's render and the reader's submit is refused there, beside the button, rather than allowed to create an account against a stale read. The adapter's own refusals name the field they belong to, and the page paints exactly that field; the row is only marked accepted once the account exists, because an account that failed to be created must leave the invitation usable rather than locking the reader out by their own typo, and the write is checked rather than assumed. Composes AuthFrame (block-local), Callout, Input, Label, PasswordInput, Spinner and Button.

Preview

Install

npx shadcn@latest add @vibra/auth-accept-invite

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

Source

app/accept-invite/page.tsx
import * as React from "react"

import { Skeleton } from "@/components/ui/skeleton"

import { AcceptInvite } from "./components/accept-invite"
import { type AuthLayout } from "./components/auth-frame"
import { defaultToken } from "./data"

/**
 * Accept an invitation into a workspace. A server component, but the only
 * thing of `db.invitations` it ever touches is `defaultToken()` — a token id,
 * not a row. The island below reads `?token=` itself and asks a server action
 * for that one row, because a block inside a framed preview is mounted as a
 * component and never given `searchParams` of its own, and a page that read
 * every invitation here to save that one round trip would serialize every
 * invitee's email into HTML the reader whose link this is never needed to see.
 *
 * The island owns the frame rather than the other way round, because a token
 * that has expired is not a smaller version of this page: it gets its own `h1`.
 * The page renders outside AppShell either way, so the frame owns the single
 * `main`. The island reads the query, so it sits inside a Suspense boundary —
 * a prerendered page that calls useSearchParams outside one fails the build.
 */
export default function AcceptInvitePage({ layout = "centered" }: { layout?: AuthLayout } = {}) {
  return (
    <React.Suspense fallback={<Skeleton className="mx-auto my-16 h-96 w-full max-w-md rounded-xl" />}>
      <AcceptInvite defaultToken={defaultToken()} layout={layout} />
    </React.Suspense>
  )
}
app/accept-invite/data.ts
/**
 * What this page reads: `db.invitations`, resolved against `db.members` for
 * whoever sent it. Only one row is ever read into a shape the reader sees —
 * `resolveInvite`, by the token in the URL — because the island asks a server
 * action for its own token's result rather than being handed the whole book:
 * a page that served every invitation would put every invitee's email in HTML
 * the reader whose link this is never needed to see.
 *
 * "Now" is `REFERENCE_DATE`: an invitation is expired when its own status says
 * so, or when its expiry has passed that fixed point — never the wall clock,
 * which would make the page read differently tomorrow.
 */
import { REFERENCE_DATE, db, type Invitation } from "@/lib/sample-data"

import { WORKSPACE, type Invite, type InviteState } from "./vocabulary"

// Fixed to UTC so the two stamps read the same wherever the page renders.
const DAY = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })

/**
 * The state a row is actually in. A row can say "pending" and still be past its
 * expiry — the status is what someone did to the invitation, the date is what
 * time did to it — so the later of the two wins.
 *
 * Exported so `actions.ts` can re-check a token's state at submit time against
 * the store, rather than trust whatever the form was rendered with minutes
 * earlier.
 */
export function stateOf(row: Invitation): Exclude<InviteState, "unknown"> {
  if (row.status === "revoked") return "revoked"
  if (row.status === "accepted") return "accepted"
  if (row.status === "expired") return "expired"
  return row.expiresAt.getTime() <= REFERENCE_DATE.getTime() ? "expired" : "pending"
}

/** The row the card shows, once it is known to be pending. */
function toInvite(row: Invitation): Invite {
  const inviter = db.members.all().find((member) => member.id === row.invitedBy)
  return {
    token: row.id,
    email: row.email,
    role: row.role,
    workspace: WORKSPACE,
    inviter: inviter?.name ?? "Someone at Northwind",
    sentLabel: DAY.format(row.sentAt),
    expiresLabel: DAY.format(row.expiresAt),
  }
}

/**
 * What can be done with a token, resolved fresh against the store: `"pending"`
 * with the row the card shows, or the state that explains why not —
 * `"unknown"` when nothing matches the token at all. `acceptInvite` and
 * `lookupInvite` both start here, so a token's state is decided in exactly one
 * place rather than drifting between a check at render time and a check at
 * submit time.
 */
export type InviteResolution =
  | { state: "pending"; invite: Invite }
  | { state: Exclude<InviteState, "pending"> }

export async function resolveInvite(token: string): Promise<InviteResolution> {
  const row = token ? await db.invitations.get(token) : undefined
  if (!row) return { state: "unknown" }
  const state = stateOf(row)
  return state === "pending" ? { state, invite: toInvite(row) } : { state }
}

const firstTokenWith = (state: Exclude<InviteState, "unknown">): string =>
  db.invitations.all().find((row) => stateOf(row) === state)?.id ?? ""

/**
 * The token the page opens on when the URL carries none: a live invitation,
 * read per request — one accepted since hands the preview on to the next.
 */
export function defaultToken(): string {
  return firstTokenWith("pending")
}

/** A token that has run out, so the state a reader most needs to see is reachable. */
export function expiredToken(): string {
  return firstTokenWith("expired")
}
app/accept-invite/vocabulary.ts
/**
 * The words this card is written in, and the shape of the row it shows.
 *
 * `data.ts` reads `db` at module scope, so a value the client island imports
 * from it would drag the whole sample-data store into the browser. These are
 * plain constants and types with no rows behind them, so they live here.
 */
import { type Invitation } from "@/lib/sample-data"

/** The workspace the invitations are into. */
export const WORKSPACE = "Northwind"

/** The workspace this card is branded for. */
export const BRAND = { name: WORKSPACE, initial: "N" }

export const SIGN_IN_HREF = "/sign-in"
export const DASHBOARD_HREF = "/saas"

/**
 * What the page can do with a token: take it, or explain why it cannot.
 * `"unknown"` is a token nothing in the store matches at all — as final as the
 * other three, so it is a state rather than a separate case the island has to
 * branch on.
 */
export type InviteState = "pending" | "expired" | "revoked" | "accepted" | "unknown"

/** One invitation, as the card shows it — only ever handed over once it is known to be pending. */
export type Invite = {
  /** The invitation's own id — the string that travels in `?token=`. */
  token: string
  email: string
  role: Invitation["role"]
  workspace: string
  /** Whoever sent it, by name. */
  inviter: string
  /** When it was sent, and when it stops working, both stamped in UTC. */
  sentLabel: string
  expiresLabel: string
}

/** The heading and the sentence under it for a token that cannot be taken. */
export const REFUSALS: Record<Exclude<InviteState, "pending">, { title: string; message: string }> = {
  expired: {
    title: "This invitation has expired",
    message:
      "Invitations stop working after a couple of weeks. Ask whoever sent it for a new one — it takes them one click.",
  },
  revoked: {
    title: "This invitation was withdrawn",
    message:
      "Someone with admin rights took this invitation back. Ask them for a new one if you still need access.",
  },
  accepted: {
    title: "This invitation has already been used",
    message: "The account exists. Sign in with it instead of accepting the invitation again.",
  },
  unknown: {
    title: "This link is not one we know",
    message:
      "The token in the address does not match any invitation. Check that the whole link was copied, including everything after the question mark.",
  },
}
app/accept-invite/actions.ts
"use server"

import { createAuthActions, isFormData, mockAuthAdapter, NOT_A_FORM, type AuthResult, type Session } from "@/lib/auth-adapter"
import { db, type Result } from "@/lib/sample-data"

import { resolveInvite } from "./data"
import { type Invite, type InviteState } from "./vocabulary"

/**
 * What this page changes. `createAuthActions` validates the submitted FormData
 * and only then calls the adapter, so pointing the page at a real backend is a
 * one-line change here: hand it your own adapter instead of the mock and
 * nothing above this file moves.
 *
 * The adapter stays on this side of the boundary. Its session is module state
 * and a real one holds a secret, so the form imports this function rather than
 * the adapter — `useActionState` takes a server action as it is.
 */
const actions = createAuthActions(mockAuthAdapter)

/**
 * The short reason a token cannot be exchanged, for the form's own error slot.
 * The card the page opens on has its own, longer copy for the same four
 * states (`REFUSALS` in `vocabulary.ts`) — this is the one-line version for a
 * refusal that shows up beside a button rather than in place of the whole
 * form, which is what happens when a token was pending when the page read it
 * and stopped being pending before the reader pressed submit.
 */
const STATE_MESSAGE: Record<Exclude<InviteState, "pending">, string> = {
  expired: "This invitation has expired.",
  revoked: "This invitation was revoked.",
  accepted: "This invitation was already used.",
  unknown: "That invitation link is not valid.",
}

/**
 * The row named by `?token=`, and what can be done with it — the same lookup
 * the page's island asks for on mount, so a token's state is answered from
 * exactly one place. Only a pending row's fields ever leave this function:
 * the book is never handed over, only the one row the caller already named.
 */
export async function lookupInvite(token: string): Promise<Result<Invite>> {
  const resolution = await resolveInvite(token)
  if (resolution.state === "pending") return { ok: true, data: resolution.invite }
  return {
    ok: false,
    error: { code: resolution.state, message: STATE_MESSAGE[resolution.state] },
  }
}

/**
 * Turns an invitation into an account.
 *
 * The email is the invitation's, not something the reader typed: an invitation
 * is *to* an address, and letting the form choose a different one would be a
 * way of claiming someone else's seat. That is enforced here, not merely
 * asserted: the hidden `email` input in the form is state the browser
 * controls, so the FormData `signUp` sees is rebuilt from the resolved row's
 * own address — a tampered field never reaches the adapter. Only once the
 * account exists is the row marked accepted — an account that failed to be
 * created must leave the invitation usable, or the reader is locked out by
 * their own typo.
 *
 * The token's state is resolved here, against the store, rather than trusted
 * from whatever the form was rendered with: the page that showed this form may
 * have read the row minutes ago, and a token that was pending then can be
 * expired, revoked or already accepted by the time this submit runs. A
 * request carrying a token in any of those states is refused before the
 * adapter is ever asked to create an account.
 */
export async function acceptInvite(formData: FormData): Promise<AuthResult<Session>> {
  if (!isFormData(formData)) return { ok: false, error: NOT_A_FORM }
  const raw = formData.get("token")
  const token = typeof raw === "string" ? raw : ""

  const resolution = await resolveInvite(token)
  if (resolution.state !== "pending") {
    return {
      ok: false,
      error: {
        code: resolution.state === "expired" ? "expired_token" : "unknown",
        message: STATE_MESSAGE[resolution.state],
      },
    }
  }

  // Copy what was submitted, then overwrite `email` with the invitation's own
  // — the one field the reader must not be able to choose.
  const verified = new FormData()
  for (const [key, value] of formData.entries()) {
    verified.set(key, value)
  }
  verified.set("email", resolution.invite.email)

  const result = await actions.signUp(verified)
  if (!result.ok) return result

  const updated = await db.invitations.update(token, { status: "accepted" })
  if (!updated.ok) {
    return {
      ok: false,
      error: {
        code: "unknown",
        message:
          "Your account was created, but the invitation could not be marked accepted. Sign in — the account is ready.",
      },
    }
  }

  return result
}
app/accept-invite/components/accept-invite.tsx
"use client"

import * as React from "react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"

import { type AuthResult, type Session } from "@/lib/auth-adapter"
import { Button, buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PasswordInput } from "@/components/ui/password-input"
import { Spinner } from "@/components/ui/spinner"

import { acceptInvite, lookupInvite } from "../actions"
import { BRAND, DASHBOARD_HREF, REFUSALS, SIGN_IN_HREF, type Invite, type InviteState } from "../vocabulary"
import { AuthFrame, type AuthLayout } from "./auth-frame"

/** What `lookupInvite` settled on for the token this page is showing. */
type Resolution = { state: "pending"; invite: Invite } | { state: Exclude<InviteState, "pending"> }

const NAME_ID = "accept-invite-name"
const NAME_ERROR_ID = "accept-invite-name-error"
const PASSWORD_ID = "accept-invite-password"
const PASSWORD_ERROR_ID = "accept-invite-password-error"

/**
 * The confirmation's heading. It is its own component so that mounting it *is*
 * the event: the effect runs exactly when the confirmation replaces the form.
 * `tabIndex={-1}` makes it a focus target without putting it in the tab order.
 */
function ConfirmationHeading({ children }: { children: React.ReactNode }) {
  const ref = React.useRef<HTMLHeadingElement>(null)
  React.useEffect(() => {
    ref.current?.focus()
  }, [])

  return (
    <h2 ref={ref} tabIndex={-1} className="text-base font-semibold tracking-tight">
      {children}
    </h2>
  )
}

/** Who invited you, into what, as what — the facts the decision rests on. */
function InviteSummary({ invite }: { invite: Invite }) {
  return (
    <dl className="flex flex-col gap-2 frame p-3 text-sm">
      <div className="flex justify-between gap-3">
        <dt className="text-muted-foreground">Invited by</dt>
        <dd className="text-right">{invite.inviter}</dd>
      </div>
      <div className="flex justify-between gap-3">
        <dt className="text-muted-foreground">Workspace</dt>
        <dd className="text-right">{invite.workspace}</dd>
      </div>
      <div className="flex justify-between gap-3">
        <dt className="text-muted-foreground">Your address</dt>
        <dd className="text-right break-all">{invite.email}</dd>
      </div>
      <div className="flex justify-between gap-3">
        <dt className="text-muted-foreground">Role</dt>
        <dd className="text-right capitalize">{invite.role}</dd>
      </div>
      <div className="flex justify-between gap-3">
        <dt className="text-muted-foreground">Good until</dt>
        <dd className="text-right tabular-nums">{invite.expiresLabel}</dd>
      </div>
    </dl>
  )
}

/** Set a name and a password, and the seat is yours. */
function JoinForm({ invite }: { invite: Invite }) {
  const [name, setName] = React.useState("")
  const [password, setPassword] = React.useState("")
  const [result, formAction, pending] = React.useActionState<AuthResult<Session> | null, FormData>(
    (_previous, formData) => acceptInvite(formData),
    null
  )

  const error = result && !result.ok ? result.error : null
  const nameInvalid = error?.field === "name"
  const passwordInvalid = error?.field === "password"

  if (result?.ok) {
    return (
      <div className="flex flex-col gap-5">
        <div className="flex flex-col gap-1.5">
          <ConfirmationHeading>You are in</ConfirmationHeading>
          <p role="status" className="text-sm text-pretty text-muted-foreground">
            {invite.email} now has a {invite.role} seat in {invite.workspace}.
          </p>
        </div>
        <Link href={DASHBOARD_HREF} className={buttonVariants()}>
          Go to dashboard
        </Link>
      </div>
    )
  }

  return (
    <form action={formAction} noValidate className="flex flex-col gap-4">
      {/* The address is the invitation's, not the reader's to change: an
          invitation is *to* an address, and a form that let it be edited would
          be a way of claiming someone else's seat. */}
      <input type="hidden" name="token" value={invite.token} />
      <input type="hidden" name="email" value={invite.email} />

      <div className="flex flex-col gap-1.5">
        <Label htmlFor={NAME_ID}>Your name</Label>
        <Input
          id={NAME_ID}
          name="name"
          autoComplete="name"
          placeholder="Ada Lovelace"
          required
          value={name}
          onChange={(event) => setName(event.target.value)}
          aria-invalid={nameInvalid || undefined}
          aria-describedby={nameInvalid ? NAME_ERROR_ID : undefined}
        />
        {nameInvalid ? (
          <p id={NAME_ERROR_ID} role="alert" className="text-sm text-danger">
            {error.message}
          </p>
        ) : null}
      </div>

      <div className="flex flex-col gap-1.5">
        <Label htmlFor={PASSWORD_ID}>Password</Label>
        <PasswordInput
          id={PASSWORD_ID}
          name="password"
          autoComplete="new-password"
          required
          showStrength
          value={password}
          onChange={(event) => setPassword(event.target.value)}
          aria-invalid={passwordInvalid || undefined}
          aria-describedby={passwordInvalid ? PASSWORD_ERROR_ID : undefined}
        />
        {passwordInvalid ? (
          <p id={PASSWORD_ERROR_ID} role="alert" className="text-sm text-danger">
            {error.message}
          </p>
        ) : null}
      </div>

      {error && !nameInvalid && !passwordInvalid ? (
        <p role="alert" className="text-sm text-danger">
          {error.message}
        </p>
      ) : null}

      <Button type="submit" disabled={pending}>
        {pending ? "Joining…" : "Join the workspace"}
      </Button>
    </form>
  )
}

/**
 * The invitation named by `?token=`, and what can be done with it.
 *
 * The token is read here rather than from the page's `searchParams`, because a
 * block rendered inside a framed preview is mounted as a component and never
 * handed any. Only the token crosses the server/client boundary as a prop
 * (`defaultToken`, for when the URL carries none) — the row it names is asked
 * for from `lookupInvite`, a server action, once this component has mounted
 * and knows which token it is actually showing. That costs the one round trip
 * a page rendered with the whole book would not have paid, in exchange for
 * never serializing an invitation this reader was not sent.
 *
 * A token that cannot be taken is not a smaller version of this page: it gets
 * the card's own `h1`, which is why the frame is rendered from in here rather
 * than around this component.
 */
export function AcceptInvite({
  defaultToken,
  layout = "centered",
}: {
  defaultToken: string
  layout?: AuthLayout
}) {
  const params = useSearchParams()
  const token = params.get("token") ?? defaultToken

  const [resolution, setResolution] = React.useState<Resolution | null>(null)
  const [isPending, startTransition] = React.useTransition()

  React.useEffect(() => {
    // A lookup that outlives its token — the URL moved on while it was in
    // flight — must not paint the previous token's answer over the new one.
    let stale = false
    startTransition(async () => {
      const result = await lookupInvite(token)
      if (stale) return
      setResolution(
        result.ok
          ? { state: "pending", invite: result.data }
          : { state: result.error.code as Exclude<InviteState, "pending"> }
      )
    })
    return () => {
      stale = true
    }
  }, [token])

  if (isPending || !resolution) {
    return (
      <AuthFrame
        title="Checking your invitation"
        description="One moment."
        brand={BRAND}
        layout={layout}
      >
        <div className="flex items-center justify-center py-6">
          <Spinner />
        </div>
      </AuthFrame>
    )
  }

  if (resolution.state !== "pending") {
    const refusal = REFUSALS[resolution.state]

    return (
      <AuthFrame
        title={refusal.title}
        description="Nothing has been created, and no seat has been taken."
        brand={BRAND}
        layout={layout}
        footer={
          <Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
            Back to sign in
          </Link>
        }
      >
        <Callout variant="warning" role="alert">
          {refusal.message}
        </Callout>
      </AuthFrame>
    )
  }

  const { invite } = resolution

  return (
    <AuthFrame
      title="Accept your invitation"
      description={`${invite.inviter} invited you to ${invite.workspace}. Set a name and a password and the seat is yours.`}
      brand={BRAND}
      layout={layout}
      footer={
        <>
          Already have an account?{" "}
          <Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
            Sign in
          </Link>
        </>
      }
    >
      <InviteSummary invite={invite} />
      <JoinForm invite={invite} />
    </AuthFrame>
  )
}
app/accept-invite/components/auth-frame.tsx
import * as React from "react"

export type AuthLayout = "centered" | "split"

export type AuthFrameProps = {
  /** The page's only h1. */
  title: string
  description: React.ReactNode
  brand: { name: string; initial: string }
  /** Sits under the card, outside its frame — the way off this page. */
  footer?: React.ReactNode
  /**
   * Goes in the split panel, under the brand. Decoration inside decoration:
   * the panel is `aria-hidden`, and a "centered" frame has no panel to put it
   * in, so nothing here may be the only place the page says something.
   */
  aside?: React.ReactNode
  /** "centered" (default) is today's single card. "split" adds a brand panel beside the card from `lg` up. */
  layout?: AuthLayout
  children: React.ReactNode
}

/**
 * The frame this page sits in. Auth pages render outside AppShell, so nothing
 * else on the route owns a landmark: the frame carries the page's single
 * `main` and its single `h1`.
 *
 * `layout` belongs to the product this page is installed in rather than to the
 * page — a template says which one it wants, and the page forwards it. The
 * card is the same either way; "split" only sets a brand panel beside it, and
 * only from `lg`, where there is room for one. Below that the two layouts are
 * the same screen: a phone gets the card, never a panel stacked above it.
 *
 * Copied into each auth block rather than shared between them. A block
 * installs as a self-contained route, so it brings its own frame with it.
 */
export function AuthFrame({
  title,
  description,
  brand,
  footer,
  aside,
  layout = "centered",
  children,
}: AuthFrameProps) {
  const card = (
    <div className="flex w-full max-w-sm flex-col gap-5">
      <div className="flex items-center justify-center gap-2">
        <span
          aria-hidden="true"
          className="flex size-6 items-center justify-center rounded-md bg-brand text-2xs font-semibold text-brand-foreground"
        >
          {brand.initial}
        </span>
        <span className="text-sm font-medium tracking-tight">{brand.name}</span>
      </div>

      <section className="flex flex-col gap-5 panel p-6">
        <header className="flex flex-col gap-1.5">
          <h1 className="type-display text-3xl text-pretty">{title}</h1>
          <p className="text-sm text-pretty text-muted-foreground">{description}</p>
        </header>
        {children}
      </section>

      {footer ? <div className="text-center text-sm text-muted-foreground">{footer}</div> : null}
    </div>
  )

  if (layout === "split") {
    return (
      <main
        data-slot="auth-frame"
        data-layout={layout}
        className="flex min-h-svh bg-surface lg:grid lg:grid-cols-2"
      >
        {/* The panel stays in the accessibility tree: it carries the brand and
            whatever the page hands it through `aside` — sign-up's headline and
            highlights, for one — so only the mark and the hairline are hidden
            as decoration. A surface token rather than the primary colour, so it
            reads as a panel in both themes instead of inverting in the dark one. */}
        <aside
          data-slot="auth-frame-panel"
          className="hidden flex-col justify-center gap-6 bg-card border-r border-border px-12 py-10 text-card-foreground lg:flex"
        >
          <span
            aria-hidden="true"
            className="flex size-11 items-center justify-center rounded-lg bg-brand text-base font-semibold text-brand-foreground"
          >
            {brand.initial}
          </span>
          <div className="flex flex-col gap-3">
            <span className="type-display text-3xl">{brand.name}</span>
            <span aria-hidden="true" className="h-px w-16 bg-border" />
          </div>

          {aside}
        </aside>

        <div className="flex flex-1 items-center justify-center px-4 py-10">{card}</div>
      </main>
    )
  }

  return (
    <main
      data-slot="auth-frame"
      data-layout={layout}
      className="flex min-h-svh items-center justify-center bg-surface px-4 py-10"
    >
      {card}
    </main>
  )
}