@cantera/connections-pagetemplate
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.
npx shadcn@latest add @cantera/connections-pageThe accounts this app can read from. Grant only what a job needs, and revoke it here when it is done.
2 of 4 connected · 1 needs attention
Installed at /connections: the page, its loading skeleton, and ConnectionsManager, the wiring over @cantera/connections-view. acc-auth-routes supplies the /api/auth/* routes; no /sign-in page is installed.
Next: 1. Fill the keys acc-auth-routes added to .env.local (see its notes above). 2. Run next dev and open /connections.
List a provider only after lib/acc-auth.ts knows it: an unwired Connect fails at the route on purpose. Reference: https://canteraui.vercel.app/components/connections-page
| Prop | Type | Default | Description |
|---|---|---|---|
| providers | OAuthProvider[] | [apsProvider] | Providers to list. Autodesk is the wired one; an extra entry renders as "not connected" and its Connect button hits /api/auth/<id>, which 404s until lib/acc-auth.ts knows that provider. |
| nextPath | string | '/connections' | Where the consent flow returns to. |
| headingLevel | 'h1' | 'h2' | 'h3' | 'h1' | Heading level for the block title, forwarded to ConnectionsView. |
This is the exact code the CLI installs into your project — you own it from there.
import { TokenError } from 'aec-auth'
import { cookies, headers } from 'next/headers'
import { ConnectionsManager } from '@/components/connections-manager'
import {
APS_PROVIDER_ID,
appOrigin,
getTokenSource,
openSession,
SESSION_COOKIE,
} from '@/lib/acc-auth'
import { apsProvider } from '@/lib/aps-oauth-preset'
import type { OAuthAccount, OAuthConnection, OAuthProvider } from '@/lib/oauth-types'
/**
* Render <AccConnections /> from any server page; the default export is a
* ready-made /connections page. Extra entries in `providers` render as "not
* connected", and their Connect button 404s until `lib/acc-auth.ts` knows the
* provider — deliberate, so an unwired provider fails loudly at the route.
*/
async function requestOrigin(): Promise<string> {
const headerList = await headers()
const host = headerList.get('x-forwarded-host') ?? headerList.get('host') ?? 'localhost:3000'
const proto = headerList.get('x-forwarded-proto') ?? 'http'
return appOrigin(`${proto}://${host}`)
}
/** Row-level failures stay on the row; only a backend that cannot answer at
* all rethrows to the page-level error state. */
function connectionFromError(
error: unknown,
account: OAuthAccount,
scopes: string[] | undefined,
): OAuthConnection {
if (error instanceof TokenError && error.code === 'not_configured') throw error
// Recoverable states are warning, not danger: a lost or revoked grant is one
// consent away. Only a provider that actually failed takes the error status.
const recoverable =
error instanceof TokenError &&
(error.code === 'consent_required' || error.code === 'grant_invalid')
return {
provider: apsProvider,
status: recoverable ? 'expired' : 'error',
account,
scopes,
error: recoverable ? undefined : 'Could not refresh the token.',
}
}
export async function AccConnections({
providers = [apsProvider],
nextPath = '/connections',
headingLevel = 'h1',
}: {
/** Providers to list. Autodesk is the wired one. */
providers?: OAuthProvider[]
nextPath?: string
/** Heading level for the block's title. Drop to h2 when embedding under one. */
headingLevel?: 'h1' | 'h2' | 'h3'
}) {
const cookieStore = await cookies()
const session = await openSession(cookieStore.get(SESSION_COOKIE)?.value)
const account = session
? { name: session.name, email: session.email, avatarUrl: session.avatarUrl }
: undefined
let connections: OAuthConnection[] = []
let error: string | undefined
if (session && account) {
try {
const origin = await requestOrigin()
try {
const token = await getTokenSource(origin).getToken({
provider: APS_PROVIDER_ID,
subject: { type: 'user', id: session.userId },
scopes: session.scopes,
})
connections = [
{
provider: apsProvider,
status: 'connected',
account,
scopes: token.scopes ? [...token.scopes] : session.scopes,
expiresAt: token.expiresAt,
},
]
} catch (tokenError) {
connections = [connectionFromError(tokenError, account, session.scopes)]
}
} catch (fatal) {
error = fatal instanceof Error ? fatal.message : 'The connection service is unavailable.'
}
}
const next = encodeURIComponent(nextPath)
return (
<ConnectionsManager
providers={providers}
connections={connections}
account={account}
status={error ? 'error' : 'ready'}
error={error}
titleAs={headingLevel}
connectHrefTemplate={`/api/auth/{provider}?next=${next}`}
disconnectHrefTemplate={`/api/auth/signout?next=${next}`}
/>
)
}
export default function ConnectionsPage() {
return (
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col p-6 sm:py-12">
<AccConnections />
</main>
)
}
import { ConnectionsView } from '@/components/connections-view'
/** The same ConnectionsView renders this, so nothing above the list moves
* when the data lands. Retitle the page and retitle this too. */
export default function ConnectionsLoadingPage() {
return (
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col p-6 sm:py-12">
<ConnectionsView providers={[]} status="loading" />
</main>
)
}
'use client'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { ConnectionsView, type ConnectionsViewProps } from '@/components/connections-view'
interface ConnectionsManagerProps
extends Omit<ConnectionsViewProps, 'onConnect' | 'onDisconnect' | 'onRetry' | 'pending' | 'ref'> {
/**
* GET target that starts consent for one provider. "{provider}" is replaced
* with the provider id, e.g. "/api/auth/{provider}?next=/connections".
*/
connectHrefTemplate: string
/**
* POST target that revokes a grant. "{provider}" is replaced with the
* provider id when the template carries it — the acc-auth-routes signout route
* takes no provider, so the default template is a plain path.
*/
disconnectHrefTemplate: string
}
/** Point the templates at your own routes, or replace this file entirely —
* ConnectionsView does not change. Both actions settle by re-rendering the
* server page: the server view is the truth, never local optimistic state. */
function ConnectionsManager({
connectHrefTemplate,
disconnectHrefTemplate,
...viewProps
}: ConnectionsManagerProps) {
const router = useRouter()
const [connecting, setConnecting] = useState<string>()
const [disconnecting, setDisconnecting] = useState<string>()
const [retrying, startRefresh] = useTransition()
function connect(providerId: string) {
// Never cleared: this page is navigating away to the consent screen.
setConnecting(providerId)
window.location.href = connectHrefTemplate.replaceAll('{provider}', providerId)
}
async function disconnect(providerId: string) {
setDisconnecting(providerId)
try {
await fetch(disconnectHrefTemplate.replaceAll('{provider}', providerId), {
method: 'POST',
redirect: 'manual',
})
} finally {
// Pending clears inside the transition so the button keeps its spinner
// until the re-rendered server page commits; refresh runs on failure too.
startRefresh(() => {
router.refresh()
setDisconnecting(undefined)
})
}
}
return (
<ConnectionsView
{...viewProps}
// The handlers are always passed: pending is a prop, never an absent
// callback, so a pressed button stays mounted through the request.
onConnect={connect}
onDisconnect={disconnect}
onRetry={() =>
startRefresh(() => {
router.refresh()
})
}
pending={{ connecting, disconnecting, retrying }}
/>
)
}
export { ConnectionsManager, type ConnectionsManagerProps }