import { computed, Default, handler, NAME, pattern, safeDateNow, Stream, TILE_UI, UI, Writable, } from "commonfabric"; type Secret = T; import { refreshOAuthToken } from "../../auth/auth-refresh.ts"; import { REFRESH_THRESHOLD_MS, startReactiveClock, } from "../../auth/auth-reactive.ts"; import type { AuthStatus } from "../../auth/auth-types.ts"; import { formatTokenExpiry, getScopeSummary as getScopeSummaryGeneric, getSelectedScopeSummary, STATUS_CONFIG, } from "../../auth/auth-ui-helpers.tsx"; // Airtable scope descriptions const SCOPE_MAP = { "data.records:read": "Read records", "data.records:write": "Write records", "data.recordComments:read": "Read record comments", "data.recordComments:write": "Write record comments", "schema.bases:read": "Read base schemas", "schema.bases:write": "Write base schemas", "webhook:manage": "Manage webhooks", } as const; // Short names for scope summary in the tile preview const SCOPE_SHORT_NAMES: Record = { "data.records:read": "Records (R)", "data.records:write": "Records (W)", "data.recordComments:read": "Comments (R)", "data.recordComments:write": "Comments (W)", "schema.bases:read": "Schema (R)", "schema.bases:write": "Schema (W)", "webhook:manage": "Webhooks", }; /** Get scope summary from granted scope strings */ export function getScopeSummary(grantedScopes: string[]): string { return getScopeSummaryGeneric(grantedScopes, SCOPE_SHORT_NAMES); } /** * Helper to create preview UI for picker display. */ export function createPreviewUI( auth: AirtableAuth | undefined, selectedScopes: Record, ): JSX.Element { const email = auth?.user?.email; const name = auth?.user?.name; const isAuthenticated = !!email; // safeDateNow() capture is intentional — createPreviewUI produces a static // snapshot for picker display, not a live-updating component. The main // pattern UI uses a reactive clock (startReactiveClock) separately. const now = safeDateNow(); const expiresAt = auth?.expiresAt || 0; const isExpired = isAuthenticated && expiresAt > 0 && expiresAt < now; const isWarning = isAuthenticated && !isExpired && expiresAt > 0 && expiresAt - now < 10 * 60 * 1000; const status: AuthStatus = !isAuthenticated ? "needs-login" : isExpired ? "expired" : isWarning ? "warning" : "ready"; const scopeSummary = isAuthenticated ? getScopeSummary(auth?.scope || []) : getSelectedScopeSummary(selectedScopes, SCOPE_SHORT_NAMES); return (
{isAuthenticated && ( {(name || email || "?")[0]?.toUpperCase()} )}
{isAuthenticated ? name || email : "Sign in required"}
{isAuthenticated && name && email && (
{email}
)} {scopeSummary && (
{scopeSummary}
)}
); } /** * Airtable OAuth token data. * * Uses `accessToken` field (OAuth2TokenSchema convention). * * CRITICAL: When consuming from another pattern, DO NOT use derive()! * Use direct property access: `airtableAuthPiece.auth` */ export type AirtableAuth = { accessToken: Secret | Default<"">; tokenType: string | Default<"">; scope: string[] | Default<[]>; expiresIn: number | Default<0>; expiresAt: number | Default<0>; refreshToken: Secret | Default<"">; user: { email: string; name: string; picture: string; } | Default<{ email: ""; name: ""; picture: "" }>; }; // Selected scopes configuration export type SelectedScopes = { "data.records:read": boolean | Default; "data.records:write": boolean | Default; "data.recordComments:read": boolean | Default; "data.recordComments:write": boolean | Default; "schema.bases:read": boolean | Default; "schema.bases:write": boolean | Default; "webhook:manage": boolean | Default; }; interface Input { selectedScopes: | SelectedScopes | Default<{ "data.records:read": true; "data.records:write": false; "data.recordComments:read": false; "data.recordComments:write": false; "schema.bases:read": true; "schema.bases:write": false; "webhook:manage": false; }>; auth: | AirtableAuth | Default<{ accessToken: ""; tokenType: ""; scope: []; expiresIn: 0; expiresAt: 0; refreshToken: ""; user: { email: ""; name: ""; picture: "" }; }>; } /** Airtable OAuth authentication for Airtable APIs. #airtableAuth */ export interface Output { auth: AirtableAuth; scopes: string[]; selectedScopes: SelectedScopes; /** Compact user display */ userChip: unknown; /** Minimal preview for picker display */ [TILE_UI]: unknown; /** Refresh the OAuth token from other pieces */ refreshToken: Stream>; /** Background updater for proactive token refresh */ bgUpdater: Stream>; } async function refreshAirtableAuthToken( auth: Writable, refreshInProgress: Writable, ): Promise { return await refreshOAuthToken( auth, "/api/integrations/airtable-oauth/refresh", refreshInProgress, ); } // Handler for refreshing tokens from UI button const handleRefresh = handler< unknown, { auth: Writable; refreshInProgress: Writable; refreshing: Writable; refreshFailed: Writable; } >( async (_event, { auth, refreshInProgress, refreshing, refreshFailed }) => { refreshing.set(true); refreshFailed.set(false); try { const didRefresh = await refreshAirtableAuthToken( auth, refreshInProgress, ); refreshing.set(false); if (!didRefresh) return; refreshFailed.set(false); } catch { refreshing.set(false); refreshFailed.set(true); } }, ); // Handler for refreshing tokens from other pieces const refreshTokenHandler = handler< Record, { auth: Writable; refreshInProgress: Writable; } >(async (_event, { auth, refreshInProgress }) => { await refreshAirtableAuthToken(auth, refreshInProgress); }); // Background updater handler for proactive token refresh const bgRefreshHandler = handler< Record, { auth: Writable; refreshInProgress: Writable; } >( async (_event, { auth, refreshInProgress }) => { const currentAuth = auth.get(); if (!currentAuth?.accessToken || !currentAuth?.refreshToken) return; const expiresAt = currentAuth.expiresAt ?? 0; if (expiresAt <= 0) return; const timeRemaining = expiresAt - safeDateNow(); if (timeRemaining > REFRESH_THRESHOLD_MS) return; console.log( "[airtable-auth bgUpdater] Token expiring soon, refreshing...", ); try { await refreshAirtableAuthToken(auth, refreshInProgress); console.log("[airtable-auth bgUpdater] Token refreshed successfully"); } catch (e) { const status = (e as { status?: number }).status; const msg = e instanceof Error ? e.message : String(e); if (status === 400 || status === 401 || status === 403) { console.error( "[airtable-auth bgUpdater] Permanent refresh failure, clearing auth:", msg, ); auth.set({ accessToken: "", tokenType: "", scope: [], expiresIn: 0, expiresAt: 0, refreshToken: "", user: { email: "", name: "", picture: "" }, }); } else { console.error( "[airtable-auth bgUpdater] Transient refresh failure:", msg, ); } } }, ); export default pattern( ({ auth, selectedScopes }) => { // Compute active scopes based on selection. // Always include user.email:read so the whoami endpoint returns the email. const scopes = computed(() => { const base: string[] = ["user.email:read"]; for (const [key, enabled] of Object.entries(selectedScopes)) { if (enabled) { base.push(key); } } return base; }); const hasSelectedScopes = computed(() => Object.values(selectedScopes).some(Boolean) ); // Check if re-auth is needed (selected scopes differ from granted) const needsReauth = computed(() => { if (!auth?.accessToken) return false; const grantedScopes: string[] = auth?.scope || []; for (const [key, enabled] of Object.entries(selectedScopes)) { if (enabled && !grantedScopes.includes(key)) { return true; } } return false; }); const now = new Writable(safeDateNow()); startReactiveClock(now); const isTokenExpired = computed(() => { if (!auth?.accessToken || !auth?.expiresAt) return false; return auth.expiresAt < now.get(); }); const tokenExpiryDisplay = computed(() => formatTokenExpiry(auth?.expiresAt || 0, now.get()) ); const checkboxesDisabled = computed(() => !!auth?.accessToken); const refreshInProgress = new Writable(false); const refreshing = new Writable(false); const refreshFailed = new Writable(false); const scopesDisplay = computed(() => scopes.join(", ")); const hasEmail = computed(() => !!auth?.user?.email); const hasUserName = computed(() => !!auth?.user?.name); // Data-only computed for the tile preview — resolves reactive values to plain scalars const previewState = computed(() => { const email = auth?.user?.email || ""; const name = auth?.user?.name || ""; const isAuthenticated = !!email; const now = safeDateNow(); const expiresAt = auth?.expiresAt || 0; const isExpired = isAuthenticated && expiresAt > 0 && expiresAt < now; const isWarning = isAuthenticated && !isExpired && expiresAt > 0 && expiresAt - now < 10 * 60 * 1000; const status: AuthStatus = !isAuthenticated ? "needs-login" : isExpired ? "expired" : isWarning ? "warning" : "ready"; const scopeSummary = isAuthenticated ? getScopeSummary(auth?.scope || []) : getSelectedScopeSummary({ "data.records:read": !!selectedScopes["data.records:read"], "data.records:write": !!selectedScopes["data.records:write"], "data.recordComments:read": !!selectedScopes["data.recordComments:read"], "data.recordComments:write": !!selectedScopes["data.recordComments:write"], "schema.bases:read": !!selectedScopes["schema.bases:read"], "schema.bases:write": !!selectedScopes["schema.bases:write"], "webhook:manage": !!selectedScopes["webhook:manage"], }, SCOPE_SHORT_NAMES); const initial = (name || email || "?")[0]?.toUpperCase() || ""; const bgColor = STATUS_CONFIG[status].bg; const dotColor = STATUS_CONFIG[status].dot; return { email, name, isAuthenticated, bgColor, dotColor, scopeSummary, initial, }; }); const loggedIn = computed(() => !!auth?.accessToken); const notLoggedInWithScopes = computed(() => !loggedIn && hasSelectedScopes ); const showTokenStatus = computed(() => !!auth?.user?.email && !isTokenExpired ); // Data-only computed for granted scopes const grantedScopesList = computed(() => { const scopeList: string[] = auth?.scope || []; return scopeList.map( (s: string) => SCOPE_MAP[s as keyof typeof SCOPE_MAP] || s, ); }); return { [NAME]: computed(() => { if (loggedIn) { return `Airtable Auth (${auth.user.email})`; } return "Airtable Auth"; }), [UI]: (

Airtable Authentication

Status: {loggedIn ? "Authenticated" : "Not Authenticated"}

{loggedIn ? (

Email: {auth.user.email}

Name: {auth.user.name}

) : (

Select permissions below and authenticate with Airtable

)}
{/* Permissions checkboxes */}

Permissions {loggedIn ? ( (locked while authenticated) ) : null}

{Object.entries(SCOPE_MAP).map(([key, description]) => ( ))}
{/* Re-auth warning */} {needsReauth ? (
Note:{" "} You've selected new permissions. Click "Authenticate with Airtable" below to grant access.
) : null} {/* Favorite reminder */} {loggedIn ? (
Tip:{" "} Favorite this piece to share your Airtable auth across all your patterns. Any pattern using{" "} wish({"{"} query: "#airtableAuth" {"}"}){" "} will automatically find and use it.
) : null} {/* Show selected scopes */} {notLoggedInWithScopes ? (
Will request: {scopesDisplay}
) : null} {/* Token expired warning */} {isTokenExpired ? (

Session Expired

Your Airtable token has expired. Click below to refresh it.

{refreshFailed ? (

Refresh failed — try signing in again below.

) : null}
) : null} {/* Show granted scopes */} {loggedIn ? (
Granted Scopes:
    {grantedScopesList.map((scope) =>
  • {scope}
  • )}
) : null} {/* Token status when authenticated and NOT expired */} {showTokenStatus ? (

Token Status

Expires in: {tokenExpiryDisplay}

) : null}
Usage:{" "} This piece provides Airtable OAuth authentication. Link its{" "} auth output to any Airtable importer piece's{" "} auth input, or favorite it for automatic discovery.
), auth, scopes, selectedScopes, userChip: hasEmail ? (
{hasUserName ? auth.user.name : auth.user.email}
{hasUserName ? (
{auth.user.email}
) : null}
) : (
Not signed in
), [TILE_UI]: (
{previewState.isAuthenticated ? ( {previewState.initial} ) : null}
{previewState.isAuthenticated ? {previewState.name || previewState.email} : Sign in required}
{previewState.isAuthenticated && !!previewState.name && !!previewState.email ? (
{previewState.email}
) : null} {previewState.scopeSummary ? (
{previewState.scopeSummary}
) : null}
), refreshToken: refreshTokenHandler({ auth, refreshInProgress }), bgUpdater: bgRefreshHandler({ auth, refreshInProgress }), }; }, );