Skip to contentVibraUI
Shared pagesinstalls at /settings/integrations

Integration settings

The connector catalogue as cards — connect, disconnect, or retry one that is refusing — filtered by the categories the catalogue actually uses.

Open the live page

The page is a server component inside AppShell: the board is db.integrations, the filter row is the categories those rows actually use with their counts, and the connected date is formatted against REFERENCE_DATE on the server rather than by a card that reads the clock. Each card posts a form carrying the direction it is in, so connectIntegration and disconnectIntegration stay the readable pair and the button says what it does; both write back through db.integrations.update. A row db already marks as error is one the provider is refusing, so retrying it fails again and says so under that card — the failure comes from the data rather than being invented for the demo. Disconnecting leaves connectedAt where it is, because the repository reads an undefined value in a patch as "not in this patch", and a date the connector was live is worth keeping. The services are fictional, like every company in the kit, and none has a logo: each card pictures its category with a lucide line icon (a chart for analytics, a shield for security), and a category the card does not know gets a plug. The section list beside the page is the nav's own: the leaves of the `/settings` entry in this block's `nav.ts`, in nav order, resolved through the same NAV_ICONS table the sidebar reads. Nothing lists the sections twice, so editing that one file — or letting a template replace it — moves the sidebar and the sub-nav together, and the sub-nav can never offer a section the product has no page for. Composes AppShell, PageHeader, SettingsLayout, SectionHeader, QuickFilters, Card, Badge, StatusBadge and EmptyState.

Preview

Install

npx shadcn@latest add @vibra/settings-integrations

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

Source

app/settings/integrations/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { formatDate } from "@/lib/format"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { SettingsLayout } from "@/components/ui/settings-layout"

import { signOut } from "./actions"
import { IntegrationsBoard } from "./components/integrations-board"
import { SETTINGS_SECTIONS } from "./components/settings-sections"
import {
  connectionCounts,
  currentUser,
  integrationCategories,
  integrations,
  shellNotifications,
} from "./data"
import { NAV, ROUTE } from "./nav"

/**
 * The category ids db stores, as a reader would say them. Anything not named
 * here is humanised from its own id, so a category added to the catalogue
 * still reads correctly without an edit here.
 */
const CATEGORY_LABELS: Record<string, string> = {
  all: "All",
  crm: "CRM",
  "developer-tools": "Developer tools",
}

function categoryLabel(value: string): string {
  const known = CATEGORY_LABELS[value]
  if (known) return known
  const words = value.replace(/-/g, " ")
  return words.charAt(0).toUpperCase() + words.slice(1)
}

/**
 * Integrations. The page is a server component inside the shell: it reads the
 * catalogue through `db.integrations`, counts the categories those rows
 * actually use, and formats every date against `REFERENCE_DATE` here rather
 * than letting a card read the clock.
 */
export default function SettingsIntegrationsPage() {
  const counts = connectionCounts()
  const connectors = integrations().map((integration) => ({
    id: integration.id,
    name: integration.name,
    provider: integration.provider,
    category: integration.category,
    categoryLabel: categoryLabel(integration.category),
    description: integration.description,
    status: integration.status,
    connectedOn: integration.connectedAt ? formatDate(integration.connectedAt, "medium", { timeZone: "UTC" }) : undefined,
  }))

  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTE}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Integrations"
        description={`${counts.connected} connected, ${counts.failing} asking to be looked at.`}
      />

      <SettingsLayout nav={SETTINGS_SECTIONS} activeHref={ROUTE}>
        <IntegrationsBoard
          connectors={connectors}
          categories={integrationCategories().map((category) => ({
            value: category.value,
            label: categoryLabel(category.value),
            count: category.count,
          }))}
        />
      </SettingsLayout>
    </AppShell>
  )
}
app/settings/integrations/nav.ts
import { type NavConfig } from "@/lib/nav-config"

/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/settings/integrations"

/**
 * This product's navigation, as plain data. AppShell resolves the icon names
 * and works out which item is current from the route, so nothing here is a
 * component and nothing here says "I am the page you are on".
 *
 * Settings is a disclosure with one leaf per settings page, so this page's own
 * route is a leaf: a parent with `items` renders as a button and has no anchor
 * for `aria-current="page"` to land on.
 */
export const NAV: NavConfig = {
  brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
  groups: [
    {
      label: "Workspace",
      items: [
        { title: "Overview", href: "/saas", icon: "layout-dashboard" },
        { title: "Customers", href: "/ecommerce/customers", icon: "users" },
        { title: "Revenue", href: "/saas/revenue", icon: "credit-card" },
        { title: "Monitoring", href: "/engineering/monitoring", icon: "activity" },
      ],
    },
    {
      label: "Account",
      items: [
        {
          title: "Settings",
          href: "/settings",
          icon: "settings",
          items: [
            // The parent route is a real page (the workspace settings block),
            // so it gets a leaf of its own — a parent with `items` renders as
            // a disclosure button and is never a link.
            { title: "General", href: "/settings", icon: "settings" },
            { title: "Profile", href: "/settings/profile", icon: "user-round" },
            { title: "Security", href: "/settings/security", icon: "shield" },
            { title: "Notifications", href: "/settings/notifications", icon: "bell" },
            { title: "API keys", href: "/settings/api-keys", icon: "key-round" },
            { title: "Integrations", href: "/settings/integrations", icon: "plug" },
          ],
        },
      ],
    },
  ],
  // Pinned under the groups, where the old secondary links sat.
  footer: [{ title: "Support", href: "/support", icon: "life-buoy" }],
}
app/settings/integrations/data.ts
/**
 * What this page reads. Every connector is a row in `db.integrations` — its
 * name, provider, category, status and the date it was connected — so the
 * board is the repository rather than a copy of it, and connecting or
 * disconnecting writes back through the same repository. The filter row is
 * the categories those rows actually use, counted; nothing here lists a
 * category the catalogue does not have. "Now" is `REFERENCE_DATE`.
 */
import { getInitials } from "@/lib/format"
import { db, type Integration, type Member } from "@/lib/sample-data"

/** Live first, then the ones that need attention, then the rest — name within each. */
const STATUS_ORDER: Record<Integration["status"], number> = { connected: 0, error: 1, available: 2 }

export function integrations(): Integration[] {
  return db.integrations.all().sort((a, b) => {
    const byStatus = STATUS_ORDER[a.status] - STATUS_ORDER[b.status]
    return byStatus !== 0 ? byStatus : a.name.localeCompare(b.name)
  })
}

export type CategoryCount = { value: string; count: number }

/** The categories the catalogue uses, busiest first. `all` leads the row. */
export function integrationCategories(): CategoryCount[] {
  const counts = new Map<string, number>()
  for (const integration of db.integrations.all()) {
    counts.set(integration.category, (counts.get(integration.category) ?? 0) + 1)
  }

  return [
    { value: "all", count: db.integrations.all().length },
    ...[...counts.entries()]
      .map(([value, count]) => ({ value, count }))
      .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value)),
  ]
}

/** How many are live, and how many are asking to be looked at. */
export function connectionCounts(): { connected: number; failing: number } {
  const rows = db.integrations.all()
  return {
    connected: rows.filter((row) => row.status === "connected").length,
    failing: rows.filter((row) => row.status === "error").length,
  }
}

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/settings/integrations/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, invalidInput, isForm, REFERENCE_DATE, type Integration, type Result } from "@/lib/sample-data"

export type ConnectionState = Result<Connection> | null

export type Connection = {
  id: string
  name: string
  status: Integration["status"]
  connectedAt?: Date
}

function view(row: Integration): Connection {
  return { id: row.id, name: row.name, status: row.status, connectedAt: row.connectedAt }
}

/**
 * Turns a connector on. A row already marked `error` is one the provider is
 * refusing, so retrying it fails again and says so — the failure is what db
 * records, not something invented for the demo.
 */
export async function connectIntegration(id: string): Promise<Result<Connection>> {
  const row = db.integrations.all().find((integration) => integration.id === id)

  if (!row) {
    return { ok: false, error: { code: "not_found", message: `No connector with id "${id}".` } }
  }
  if (row.status === "connected") {
    return {
      ok: false,
      error: { code: "invalid_input", message: `${row.name} is already connected.` },
    }
  }
  if (row.status === "error") {
    return {
      ok: false,
      error: {
        code: "unavailable",
        message: `${row.name} refused the handshake. Check the account at the provider, then try again.`,
      },
    }
  }

  const updated = await db.integrations.update(id, {
    status: "connected",
    connectedAt: REFERENCE_DATE,
  })
  if (!updated.ok) return updated
  return { ok: true, data: view(updated.data) }
}

/**
 * Turns a connector off. `connectedAt` is left where it is — the repository
 * reads an undefined value in a patch as "not in this patch" rather than
 * "erase it", and a date the connector *was* live is worth keeping anyway.
 */
export async function disconnectIntegration(id: string): Promise<Result<Connection>> {
  const row = db.integrations.all().find((integration) => integration.id === id)

  if (!row) {
    return { ok: false, error: { code: "not_found", message: `No connector with id "${id}".` } }
  }
  if (row.status === "available") {
    return {
      ok: false,
      error: { code: "invalid_input", message: `${row.name} is not connected.` },
    }
  }

  const updated = await db.integrations.update(id, { status: "available" })
  if (!updated.ok) return updated
  return { ok: true, data: view(updated.data) }
}

/**
 * What each card's form posts to. The direction is the card's own state at the
 * moment it was submitted, so the two actions above stay the readable pair a
 * caller reaches for, and the form still has one function to name.
 */
export async function toggleIntegration(
  _previous: ConnectionState,
  formData: FormData
): Promise<Result<Connection>> {
  if (!isForm(formData)) return invalidInput("Send the card's form as the page sends it.")
  const id = formData.get("id")
  const intent = formData.get("intent")

  if (typeof id !== "string" || !id) {
    return { ok: false, error: { code: "invalid_input", message: "No connector was named." } }
  }

  return intent === "disconnect" ? disconnectIntegration(id) : connectIntegration(id)
}

/**
 * The one thing the shell calls. A server action so the page can stay a server
 * component and still hand the shell something to call, and a `Result` so the
 * caller reads the same success-or-error shape every mutation returns.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}
app/settings/integrations/components/integration-card.tsx
"use client"

import * as React from "react"
import {
  ActivityIcon,
  ChartLineIcon,
  CodeIcon,
  ContactIcon,
  CreditCardIcon,
  DatabaseIcon,
  HardDriveIcon,
  LifeBuoyIcon,
  MegaphoneIcon,
  MessagesSquareIcon,
  NotebookPenIcon,
  PenToolIcon,
  PlugIcon,
  ShieldCheckIcon,
  WorkflowIcon,
  type LucideIcon,
} from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { StatusBadge } from "@/components/ui/status-badge"

import { toggleIntegration, type ConnectionState } from "../actions"

export type ConnectorStatus = "connected" | "available" | "error"

export type Connector = {
  id: string
  name: string
  provider: string
  category: string
  categoryLabel: string
  description: string
  status: ConnectorStatus
  /** Already formatted against REFERENCE_DATE; the card never reads a clock. */
  connectedOn?: string
}

/**
 * What each category is pictured with. The services are fictional and have no
 * logos, so a card shows what kind of thing it connects; a category the map
 * does not know gets the plug.
 */
const CATEGORY_ICONS: Record<string, LucideIcon> = {
  communication: MessagesSquareIcon,
  "developer-tools": CodeIcon,
  payments: CreditCardIcon,
  analytics: ChartLineIcon,
  automation: WorkflowIcon,
  crm: ContactIcon,
  observability: ActivityIcon,
  support: LifeBuoyIcon,
  productivity: NotebookPenIcon,
  design: PenToolIcon,
  storage: HardDriveIcon,
  security: ShieldCheckIcon,
  marketing: MegaphoneIcon,
  data: DatabaseIcon,
}

const STATUS_LABEL: Record<ConnectorStatus, string> = {
  connected: "Connected",
  available: "Not connected",
  error: "Needs attention",
}

const STATUS_MAP = { available: "neutral", error: "danger", connected: "success" } as const

/**
 * One connector. Its form posts the direction the card is in, so the button
 * says what it does, and whatever the action refuses is written under it.
 */
export function IntegrationCard({ connector }: { connector: Connector }) {
  const [state, formAction, pending] = React.useActionState<ConnectionState, FormData>(
    toggleIntegration,
    null
  )

  // The action's answer wins over the row the page was rendered with.
  const status = state?.ok ? (state.data.status as ConnectorStatus) : connector.status
  const error = state && !state.ok ? state.error.message : undefined
  const connected = status === "connected"
  const verb = connected ? "Disconnect" : status === "error" ? "Try again" : "Connect"
  const Icon = CATEGORY_ICONS[connector.category] ?? PlugIcon

  return (
    <Card data-slot="integration-card" className="h-full">
      <CardHeader>
        <div className="flex items-start justify-between gap-3">
          <div className="flex min-w-0 items-center gap-3">
            <span
              data-slot="integration-icon"
              aria-hidden="true"
              className="flex size-9 shrink-0 items-center justify-center rounded-md bg-surface text-muted-foreground ring-1 ring-foreground/10"
            >
              <Icon className="size-4" />
            </span>
            <div className="flex min-w-0 flex-col gap-1">
              <h3 className="truncate text-sm font-medium">{connector.name}</h3>
              <Badge variant="outline" className="w-fit text-avatar font-normal">
                {connector.categoryLabel}
              </Badge>
            </div>
          </div>
          <StatusBadge
            status={status}
            map={STATUS_MAP}
            size="sm"
            label={STATUS_LABEL[status]}
            className="shrink-0"
          />
        </div>
      </CardHeader>

      <CardContent className="flex flex-1 flex-col gap-2">
        <p className="text-sm text-muted-foreground">{connector.description}</p>
        {connector.connectedOn ? (
          <p className="text-xs text-muted-foreground">
            {connected ? "Connected" : "Last connected"} {connector.connectedOn}
          </p>
        ) : null}
        {error ? (
          <p role="alert" className="text-sm text-danger">
            {error}
          </p>
        ) : null}

        <form action={formAction} className="mt-auto pt-3">
          <input type="hidden" name="id" value={connector.id} />
          <input type="hidden" name="intent" value={connected ? "disconnect" : "connect"} />
          {/* Named outright: twenty-four buttons all reading "Connect" are one
              list to a reader moving between them, and the accessible name
              still opens with the visible word. */}
          <Button
            type="submit"
            variant={connected ? "ghost" : "outline"}
            size="sm"
            disabled={pending}
            aria-busy={pending || undefined}
            aria-label={`${verb} ${connector.name}`}
          >
            {verb}
          </Button>
        </form>
      </CardContent>
    </Card>
  )
}
app/settings/integrations/components/integrations-board.tsx
"use client"

import * as React from "react"

import { EmptyState } from "@/components/ui/empty-state"
import { QuickFilters } from "@/components/ui/quick-filters"
import { SectionHeader } from "@/components/ui/section-header"

import { IntegrationCard, type Connector } from "./integration-card"

export type CategoryOption = { value: string; label: string; count: number }

export type IntegrationsBoardProps = {
  connectors: Connector[]
  categories: CategoryOption[]
}

/**
 * The catalogue, filtered by the categories the rows themselves use. The
 * section carries the heading rather than a card, because the connectors are
 * already cards and a card of cards is one frame too many.
 */
export function IntegrationsBoard({ connectors, categories }: IntegrationsBoardProps) {
  const [category, setCategory] = React.useState("all")

  const shown =
    category === "all"
      ? connectors
      : connectors.filter((connector) => connector.category === category)

  return (
    <section aria-label="Connectors" className="flex flex-col gap-4">
      <SectionHeader
        as="h2"
        divider
        title="Connectors"
        description="What Northwind can talk to. Connecting one hands it the scopes it asks for."
      />

      <QuickFilters
        size="sm"
        aria-label="Category"
        value={category}
        onValueChange={setCategory}
        options={categories.map((option) => ({
          value: option.value,
          label: option.label,
          count: option.count,
        }))}
      />

      {shown.length === 0 ? (
        <EmptyState
          variant="dashed"
          size="sm"
          title="Nothing in this category"
          description="Choose another category, or go back to all of them."
        />
      ) : (
        <div className="grid gap-4 lg:grid-cols-2">
          {shown.map((connector) => (
            <IntegrationCard key={connector.id} connector={connector} />
          ))}
        </div>
      )}
    </section>
  )
}
app/settings/integrations/components/settings-sections.tsx
import { flattenNav, type NavConfig } from "@/lib/nav-config"
import { navIcon } from "@/components/ui/app-shell/icons"
import { type SettingsNavItem } from "@/components/ui/settings-layout"

import { NAV } from "../nav"

// Not `SETTINGS_HREF`: this is the entry to look up, not a link. The template
// generator reads any *href constant holding an absolute path as a route a
// page links to, and would stub "/settings" for a product that has no page
// there — the same reason a page nav's own ROUTE is not called a href.
/** The route the settings area is rooted at. */
const SETTINGS_ROOT = "/settings"

/**
 * The settings area's own sections, as SettingsLayout wants them: the leaves of
 * the nav's `/settings` entry, in nav order. Read from the nav rather than
 * written out a second time, because a template replaces `nav.ts` with its own
 * — a hand-listed set would then offer sections that product has no page for,
 * and the sub-nav beside the page would disagree with the sidebar above it.
 *
 * `findNavItem` is not the lookup: these navs give `/settings` a leaf of its
 * own (General, the area's front page), and that leaf ties with its parent on
 * href length, so the entry that owns the list has to be asked for directly.
 * An entry with no leaves is its own only section.
 *
 * Each section is a real route, so these stay plain anchors — no `onNavigate`,
 * no client state — and SettingsLayout marks the one matching `activeHref` as
 * the current page.
 */
export function settingsSections(nav: NavConfig = NAV): SettingsNavItem[] {
  const entries = flattenNav(nav).filter((item) => item.href === SETTINGS_ROOT)
  // The parent wins over its own General leaf, which shares its href: the one
  // that carries the list is the one being asked for. `findNavItem` would hand
  // back the leaf instead, since equal href lengths go to the later item.
  const settings = entries.find((item) => item.items?.length) ?? entries[0]
  if (!settings) return []
  const leaves = settings.items?.length ? settings.items : [settings]

  return leaves.map((leaf) => {
    // A NavConfig names its icons rather than holding them; this is the table
    // the sidebar resolves them through, so the two cannot drift.
    const Icon = navIcon(leaf.icon)
    return { title: leaf.title, href: leaf.href, icon: Icon ? <Icon /> : undefined }
  })
}

/** This block's own sections: the nav it ships with, resolved once. */
export const SETTINGS_SECTIONS: SettingsNavItem[] = settingsSections()