{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "connections-page", "title": "Connections Page", "author": "MR ", "description": "The page that manages every provider grant: one card per connection, with connect, reconnect, and disconnect — plus the designed empty, loading, and error states a real fetch needs.", "dependencies": [ "aec-auth", "lucide-react" ], "registryDependencies": [ "button", "@cantera/connection-card", "@cantera/provider-sign-in-button", "@cantera/token-status", "@cantera/user-account-badge", "@cantera/acc-sign-in", "@cantera/aps-oauth-preset", "@cantera/oauth-types", "@cantera/status-tokens" ], "files": [ { "path": "registry/blocks/connections-page/page.tsx", "content": "import { TokenError } from 'aec-auth'\nimport { cookies, headers } from 'next/headers'\n\nimport { ConnectionsManager } from '@/components/connections-manager'\nimport { APS_PROVIDER_ID, getTokenSource, openSession, SESSION_COOKIE } from '@/lib/acc-auth'\nimport { apsProvider } from '@/lib/aps-oauth-preset'\nimport type { OAuthAccount, OAuthConnection, OAuthProvider } from '@/lib/oauth-types'\n\n/**\n * The connections-page block: every provider grant this app holds, on one\n * page, with connect, reconnect, and disconnect per provider.\n *\n * Server-rendered on the same aec-auth wiring the acc-sign-in block installs —\n * `lib/acc-auth.ts` and the `/api/auth/*` route handlers come from that item,\n * so this block adds a page and its client wiring, and nothing token-shaped.\n *\n * Reusable inner component: render from any server page;\n * the default export is a ready-made /connections page, and the sibling\n * loading.tsx is its skeleton.\n *\n * Autodesk is the wired provider. Extra entries in `providers` render as \"not\n * connected\" and their Connect button hits `/api/auth/`, which 404s until\n * you teach `lib/acc-auth.ts` about that provider — deliberate, so an unwired\n * provider fails loudly at the route rather than quietly in the UI.\n */\n\nasync function requestOrigin(): Promise {\n const headerList = await headers()\n const host = headerList.get('x-forwarded-host') ?? headerList.get('host') ?? 'localhost:3000'\n const proto = headerList.get('x-forwarded-proto') ?? 'http'\n return `${proto}://${host}`\n}\n\n/**\n * Row-level failures stay on the row. Only a backend that cannot answer at all\n * — no client id, no provider configured — throws to the page-level state,\n * because then there is nothing to render a row about.\n */\nfunction connectionFromError(\n error: unknown,\n account: OAuthAccount,\n scopes: string[] | undefined,\n): OAuthConnection {\n if (error instanceof TokenError && error.code === 'not_configured') throw error\n\n // Recoverable states are warning, not danger: a lost or revoked grant is one\n // consent away. Only a provider that actually failed takes the error status.\n const recoverable =\n error instanceof TokenError &&\n (error.code === 'consent_required' || error.code === 'grant_invalid')\n\n return {\n provider: apsProvider,\n status: recoverable ? 'expired' : 'error',\n account,\n scopes,\n error: recoverable ? undefined : 'Could not refresh the token.',\n }\n}\n\nexport async function AccConnections({\n providers = [apsProvider],\n nextPath = '/connections',\n headingLevel = 'h1',\n}: {\n /** Providers to list, in display order. Autodesk is the wired one. */\n providers?: OAuthProvider[]\n /** Where the consent flow returns to. */\n nextPath?: string\n /** Heading level for the block's title. Drop to h2 when embedding under one. */\n headingLevel?: 'h1' | 'h2' | 'h3'\n}) {\n const cookieStore = await cookies()\n const session = await openSession(cookieStore.get(SESSION_COOKIE)?.value)\n const account = session\n ? { name: session.name, email: session.email, avatarUrl: session.avatarUrl }\n : undefined\n\n let connections: OAuthConnection[] = []\n let error: string | undefined\n\n if (session && account) {\n try {\n const origin = await requestOrigin()\n try {\n const token = await getTokenSource(origin).getToken({\n provider: APS_PROVIDER_ID,\n subject: { type: 'user', id: session.userId },\n scopes: session.scopes,\n })\n connections = [\n {\n provider: apsProvider,\n status: 'connected',\n account,\n scopes: token.scopes ? [...token.scopes] : session.scopes,\n expiresAt: token.expiresAt,\n },\n ]\n } catch (tokenError) {\n connections = [connectionFromError(tokenError, account, session.scopes)]\n }\n } catch (fatal) {\n error = fatal instanceof Error ? fatal.message : 'The connection service is unavailable.'\n }\n }\n\n const next = encodeURIComponent(nextPath)\n\n return (\n \n )\n}\n\n// The grant state is read from cookies and the vault on every visit.\nexport const dynamic = 'force-dynamic'\n\nexport default function ConnectionsPage() {\n return (\n
\n \n
\n )\n}\n", "type": "registry:page", "target": "app/connections/page.tsx" }, { "path": "registry/blocks/connections-page/loading.tsx", "content": "import { ConnectionsView } from '@/components/connections-view'\n\n/**\n * The route's loading UI, so the block's loading state is the real thing the\n * App Router streams rather than a decoration in a demo.\n *\n * The same ConnectionsView renders it: heading and description are already\n * final here, and only the list is placeholder — so nothing above the list\n * moves when the data lands. Retitle the page and retitle this too.\n */\nexport default function ConnectionsLoadingPage() {\n return (\n
\n \n
\n )\n}\n", "type": "registry:file", "target": "app/connections/loading.tsx" }, { "path": "registry/blocks/connections-page/components/connections-view.tsx", "content": "'use client'\n\nimport { LoaderCircleIcon, RotateCwIcon } from 'lucide-react'\nimport type * as React from 'react'\nimport { useId, useState } from 'react'\n\nimport { Button } from '@/components/ui/button'\nimport { ConnectionCard } from '@/components/ui/connection-card'\nimport { ProviderSignInButton } from '@/components/ui/provider-sign-in-button'\nimport { statusInkClasses } from '@/components/ui/token-status'\nimport { UserAccountBadge } from '@/components/ui/user-account-badge'\nimport {\n isExpiringSoon,\n type OAuthAccount,\n type OAuthConnection,\n type OAuthProvider,\n} from '@/lib/oauth-types'\nimport { cn } from '@/lib/utils'\n\n/**\n * The presentational half of the connections-page block: every grant this app\n * holds, on one page, with connect / reconnect / disconnect per provider.\n *\n * Data-agnostic on purpose — connections in, callbacks out, no fetching. The\n * wiring lives in ConnectionsManager next door; swap it for your own backend\n * and this file does not change.\n *\n * Four states, all shipped and all exported so they survive being adapted:\n * - loading — the initial fetch. Static skeleton rows at the real row\n * geometry, so nothing shifts when the data lands, plus one live spinner.\n * - error — the whole fetch failed. Message plus a retry on the\n * async-pending contract. A single provider that failed is not this state:\n * it is a row with status \"error\", which keeps its healthy siblings visible.\n * - empty — nothing connected yet. The provider chooser IS the empty state.\n * - ready — the mixed dashboard: connected, expiring, expired, errored, and\n * not-yet-connected providers in one list.\n */\n\n/** What the page is showing. \"ready\" with nothing connected renders the empty state. */\ntype ConnectionsStatus = 'ready' | 'loading' | 'error'\n\n/**\n * Consumer-driven pending, for wiring where no promise comes back (a server\n * action, or a navigation that never resolves). A callback that returns a\n * promise drives the same states on its own.\n *\n * One provider id, not a set: a second consent redirect would race the first,\n * the same reason SignInCard takes a single `loadingProvider`.\n */\ninterface ConnectionsPending {\n /** Provider id whose connect / reconnect is in flight. */\n connecting?: string\n /** Provider id whose disconnect is in flight. */\n disconnecting?: string\n /** The page-level retry, shown only in the error state. */\n retrying?: boolean\n}\n\n/**\n * One row per provider, in catalog order: the grant where one exists, a\n * \"disconnected\" placeholder where it does not, and any grant for a provider\n * outside the catalog appended rather than silently dropped.\n *\n * Exported because it is the whole data model — an adapter feeding this page\n * from another backend reimplements nothing.\n */\nfunction resolveConnections(\n providers: OAuthProvider[],\n connections: OAuthConnection[] = [],\n): OAuthConnection[] {\n const held = new Map(connections.map((connection) => [connection.provider.id, connection]))\n const rows: OAuthConnection[] = providers.map(\n (provider) => held.get(provider.id) ?? { provider, status: 'disconnected' },\n )\n const known = new Set(providers.map((provider) => provider.id))\n return [...rows, ...connections.filter((connection) => !known.has(connection.provider.id))]\n}\n\n// ---------------------------------------------------------------------------\n// Loading\n// ---------------------------------------------------------------------------\n\ninterface ConnectionsLoadingProps extends React.ComponentProps<'div'> {\n /** How many skeleton rows to draw. Match the provider count when you know it. */\n rows?: number\n}\n\n/**\n * The initial fetch. Deliberately still: the motion grammar is four moves and\n * a looping shimmer is not one of them — on a list of rows it is an attention\n * magnet with nothing to say, and it reads as activity where there is none.\n * The single spinner is the sanctioned move, and it carries the announcement.\n *\n * The skeleton's job is geometry, not entertainment: these rows are built from\n * the same box model as ConnectionCard, so the real cards land exactly where\n * the placeholders stood. No stagger, no entrance — the data is dense.\n */\nfunction ConnectionsLoading({ rows = 3, className, ...props }: ConnectionsLoadingProps) {\n const placeholders = Array.from({ length: Math.max(rows, 1) }, (_, index) => `skeleton-${index}`)\n\n return (\n \n {/* is implicitly role=\"status\" aria-live=\"polite\" — the skeleton\n itself is decorative and hidden, so this is the only thing announced. */}\n \n \n Loading connections\n \n {placeholders.map((id) => (\n \n
\n {/* Provider row: mark, name, action — matches the card's header line. */}\n
\n
\n
\n
\n
\n {/* Account badge row: avatar plus the name-over-email stack, whose\n two 16px lines are what sets a full card's height. */}\n
\n
\n
\n
\n
\n
\n
\n {/* Status line: one badge plus the expiry text. */}\n
\n
\n
\n
\n
\n
\n ))}\n
\n )\n}\n\n// ---------------------------------------------------------------------------\n// Error\n// ---------------------------------------------------------------------------\n\ninterface ConnectionsErrorProps extends React.ComponentProps<'div'> {\n /** What failed, in the user's words. Falls back to a generic sentence. */\n message?: string\n onRetry?: () => void | Promise\n /** Consumer-driven pending for the retry. A returned promise drives it too. */\n retryPending?: boolean\n}\n\n/**\n * The retry, on the async-pending contract: stays mounted, keeps its label,\n * crossfades its icon to a spinner, and blocks activation through\n * aria-disabled so focus is never dropped mid-request.\n */\nfunction RetryButton({\n onRetry,\n pending = false,\n describedBy,\n}: {\n onRetry: () => void | Promise\n pending?: boolean\n describedBy?: string\n}) {\n const [asyncPending, setAsyncPending] = useState(false)\n const busy = pending || asyncPending\n\n return (\n {\n const result = onRetry()\n if (!(result instanceof Promise)) return\n setAsyncPending(true)\n result.then(\n () => setAsyncPending(false),\n () => setAsyncPending(false),\n )\n }}\n >\n \n \n \n \n Try again\n \n )\n}\n\n/**\n * The whole fetch failed, so there is no list to show. Page-level only — a\n * single provider that errored keeps its row and its siblings.\n */\nfunction ConnectionsError({\n message,\n onRetry,\n retryPending,\n className,\n ...props\n}: ConnectionsErrorProps) {\n const messageId = useId()\n const detail = message ?? 'The connection service did not respond.'\n\n return (\n \n
\n

Could not load your connections

\n {/* Danger ink from the status palette, not the theme's own destructive:\n one color, one meaning, and it is contrast-verified in both\n appearances against the page background. */}\n

\n {detail}\n

\n
\n {onRetry && }\n
\n )\n}\n\n// ---------------------------------------------------------------------------\n// Empty\n// ---------------------------------------------------------------------------\n\ninterface ConnectionsEmptyProps extends React.ComponentProps<'div'> {\n providers: OAuthProvider[]\n onConnect?: (providerId: string) => void | Promise\n /** Provider id whose consent flow is in flight. */\n connecting?: string\n}\n\n/**\n * Nothing connected yet. The provider chooser IS the empty state: the page\n * says what a connection buys and offers the one action worth taking, rather\n * than narrating the absence and making the user hunt for the button. No\n * illustration — the system is monochrome, and a drawing would be the loudest\n * thing on a page whose job is data.\n */\nfunction ConnectionsEmpty({\n providers,\n onConnect,\n connecting,\n className,\n ...props\n}: ConnectionsEmptyProps) {\n const hintId = useId()\n\n return (\n \n
\n

No connections yet

\n

\n Connect a provider to pull its projects, documents, and models into this app. You pick the\n scopes during consent, and you can disconnect from this page at any time.\n

\n
\n {providers.length > 0 && (\n
\n {providers.map((provider) => (\n onConnect(provider.id) : undefined}\n loading={connecting === provider.id}\n // One consent flow at a time: a second redirect races the first.\n disabled={connecting !== undefined && connecting !== provider.id}\n >\n Connect {provider.name}\n \n ))}\n
\n )}\n
\n )\n}\n\n// ---------------------------------------------------------------------------\n// List\n// ---------------------------------------------------------------------------\n\ninterface ConnectionsListProps extends React.ComponentProps<'ul'> {\n connections: OAuthConnection[]\n onConnect?: (providerId: string) => void | Promise\n onDisconnect?: (providerId: string) => void | Promise\n pending?: ConnectionsPending\n showScopes?: boolean\n}\n\n/**\n * The mixed dashboard. One ConnectionCard per row, which is where the whole\n * status vocabulary shows up at once: connected, expiring soon, expired,\n * errored, and never-connected all sit in the same list.\n */\nfunction ConnectionsList({\n connections,\n onConnect,\n onDisconnect,\n pending,\n showScopes = true,\n className,\n ...props\n}: ConnectionsListProps) {\n return (\n
    \n {connections.map((connection) => {\n const providerId = connection.provider.id\n return (\n
  • \n onConnect(providerId) : undefined}\n onDisconnect={onDisconnect ? () => onDisconnect(providerId) : undefined}\n reconnectPending={pending?.connecting === providerId}\n disconnectPending={pending?.disconnecting === providerId}\n showScopes={showScopes}\n />\n
  • \n )\n })}\n
\n )\n}\n\n// ---------------------------------------------------------------------------\n// The page\n// ---------------------------------------------------------------------------\n\ninterface ConnectionsViewProps extends Omit, 'title'> {\n /** Every provider this app can connect to, in display order. */\n providers: OAuthProvider[]\n /** The grants that exist, matched to providers by `connection.provider.id`. */\n connections?: OAuthConnection[]\n /** Fetch state. Loading and error take over the list; the heading stays put. */\n status?: ConnectionsStatus\n /** Page-level failure detail, shown when status is \"error\". */\n error?: string\n /** Who these grants belong to. Rendered beside the heading when set. */\n account?: OAuthAccount\n onConnect?: (providerId: string) => void | Promise\n onDisconnect?: (providerId: string) => void | Promise\n onRetry?: () => void | Promise\n pending?: ConnectionsPending\n title?: React.ReactNode\n /** Heading level for the page title. Drop to h2 when embedding under one. */\n titleAs?: 'h1' | 'h2' | 'h3'\n description?: React.ReactNode\n showScopes?: boolean\n}\n\n/**\n * The connections page: a real heading, an at-a-glance summary, and one row\n * per provider — or the empty, loading, or error state that replaces the list.\n */\nfunction ConnectionsView({\n providers,\n connections,\n status = 'ready',\n error,\n account,\n onConnect,\n onDisconnect,\n onRetry,\n pending,\n title = 'Connections',\n titleAs: Heading = 'h1',\n description = 'The accounts this app can read from. Grant only what a job needs, and revoke it here when it is done.',\n showScopes = true,\n className,\n ...props\n}: ConnectionsViewProps) {\n const rows = resolveConnections(providers, connections)\n const connected = rows.filter((row) => row.status === 'connected').length\n const expired = rows.filter((row) => row.status === 'expired').length\n const failed = rows.filter((row) => row.status === 'error').length\n // Same predicate TokenStatus renders \"Expiring soon\" from, so the count can\n // never disagree with a warning shown on a card below it.\n const expiring = rows.filter((row) => row.status === 'connected' && isExpiringSoon(row)).length\n const attention = expired + failed + expiring\n const isEmpty = rows.every((row) => row.status === 'disconnected')\n\n let body: React.ReactNode\n if (status === 'loading') {\n body = 0 ? providers.length : 3} />\n } else if (status === 'error') {\n body = \n } else if (isEmpty) {\n body = (\n \n )\n } else {\n body = (\n \n )\n }\n\n return (\n \n
\n {/* The identity sits on the heading line rather than under the prose:\n whose grants these are is a fact about the page, not a caption. */}\n
\n {title}\n {account && }\n
\n {description &&

{description}

}\n {status === 'ready' && !isEmpty && (\n

\n \n {connected} of {rows.length} connected\n \n {attention > 0 && (\n <>\n {' · '}\n {/* The worst row sets the tone: an outright failure is danger,\n an expiry is recoverable and stays warning. */}\n 0 ? statusInkClasses.danger : statusInkClasses.warning}>\n {attention}{' '}\n {attention === 1 ? 'needs' : 'need'} attention\n \n \n )}\n

\n )}\n
\n {body}\n \n )\n}\n\nexport {\n ConnectionsEmpty,\n type ConnectionsEmptyProps,\n ConnectionsError,\n type ConnectionsErrorProps,\n ConnectionsList,\n type ConnectionsListProps,\n ConnectionsLoading,\n type ConnectionsLoadingProps,\n type ConnectionsPending,\n type ConnectionsStatus,\n ConnectionsView,\n type ConnectionsViewProps,\n resolveConnections,\n}\n", "type": "registry:component", "target": "components/connections-view.tsx" }, { "path": "registry/blocks/connections-page/components/connections-manager.tsx", "content": "'use client'\n\nimport { useRouter } from 'next/navigation'\nimport { useState, useTransition } from 'react'\n\nimport { ConnectionsView, type ConnectionsViewProps } from '@/components/connections-view'\n\ninterface ConnectionsManagerProps\n extends Omit {\n /**\n * GET target that starts consent for one provider. \"{provider}\" is replaced\n * with the provider id, e.g. \"/api/auth/{provider}?next=/connections\".\n */\n connectHrefTemplate: string\n /**\n * POST target that revokes a grant. \"{provider}\" is replaced with the\n * provider id when the template carries it — the acc-sign-in signout route\n * takes no provider, so the default template is a plain path.\n */\n disconnectHrefTemplate: string\n}\n\n/**\n * Client wiring for the connections-page block: connect navigates to the\n * consent route, disconnect posts to the revoke route, and both settle by\n * re-rendering the server page — the server view is the truth, never local\n * optimistic state about a token.\n *\n * The page component next door stays presentational; point this at your own\n * routes, or replace it entirely, and ConnectionsView does not change.\n */\nfunction ConnectionsManager({\n connectHrefTemplate,\n disconnectHrefTemplate,\n ...viewProps\n}: ConnectionsManagerProps) {\n const router = useRouter()\n const [connecting, setConnecting] = useState()\n const [disconnecting, setDisconnecting] = useState()\n // A transition keeps the retry pending until the server page has actually\n // re-rendered, rather than for the length of a fire-and-forget call.\n const [retrying, startRefresh] = useTransition()\n\n function connect(providerId: string) {\n // A full navigation to the provider's consent screen: the spinner stays up\n // because this page is on its way out, which is exactly right.\n setConnecting(providerId)\n window.location.href = connectHrefTemplate.replaceAll('{provider}', providerId)\n }\n\n async function disconnect(providerId: string) {\n setDisconnecting(providerId)\n try {\n await fetch(disconnectHrefTemplate.replaceAll('{provider}', providerId), {\n method: 'POST',\n redirect: 'manual',\n })\n } finally {\n // Clearing pending is part of the same transition as the refresh, so the\n // Disconnect control keeps its spinner until the re-rendered server page\n // commits — never actionable again beside a stale connected row. The\n // refresh runs on failure too: the server view is the truth either way.\n startRefresh(() => {\n router.refresh()\n setDisconnecting(undefined)\n })\n }\n }\n\n return (\n \n startRefresh(() => {\n router.refresh()\n })\n }\n pending={{ connecting, disconnecting, retrying }}\n />\n )\n}\n\nexport { ConnectionsManager, type ConnectionsManagerProps }\n", "type": "registry:component", "target": "components/connections-manager.tsx" } ], "envVars": { "APS_CLIENT_ID": "", "APS_CLIENT_SECRET": "", "SESSION_SECRET": "", "APS_AUTH_BASE_URL": "" }, "docs": "Installed: app/connections/page.tsx with its loading skeleton, plus ConnectionsView (presentational) and ConnectionsManager (wiring).\n\nThis block takes acc-sign-in as a registry dependency for the /api/auth/* routes and the aec-auth glue, so one environment configures both:\n- APS_CLIENT_ID / APS_CLIENT_SECRET — your APS app credentials.\n- SESSION_SECRET — required in production; the block fails closed without it.\n- APS_AUTH_BASE_URL — optional auth-origin override for an emulator. Unset means real APS.\n\nACC_AUTH_DEMO=1 allows the insecure fallback session secret and must never be set where real accounts exist.\n\nConnectionsView never fetches — providers and connections in, callbacks out — so point it at any backend.", "categories": [ "authentication", "connections", "dashboard", "autodesk" ], "type": "registry:block" }