/settings/integrationsIntegration settings
The connector catalogue as cards — connect, disconnect, or retry one that is refusing — filtered by the categories the catalogue actually uses.
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
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>
)
}Install
npx shadcn@latest add @vibra/settings-integrationsNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}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" }],
}/**
* 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 }))
}"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 } }
}"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>
)
}"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>
)
}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()