From dc39b1c01ce3d8d30f6b4fc103527ca0994ec644 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Mon, 13 Apr 2026 14:07:30 -0400 Subject: [PATCH] Surface FORBIDDEN/OAuth restriction errors as persistent warnings Detect GitHub FORBIDDEN errors from organizations with OAuth App access restrictions and surface them as dismissible bottom-bar warnings prompting the user to configure access, instead of silently dropping data. --- .../layouts/dashboard-error-screen.tsx | 13 ++++++- .../components/layouts/dashboard-layout.tsx | 10 +++++- apps/dashboard/src/lib/github-cache.ts | 30 ++++++++++++++++ apps/dashboard/src/lib/github.functions.ts | 36 +++++++++++++++++-- apps/dashboard/src/lib/github.types.ts | 2 ++ apps/dashboard/src/lib/warning-store.ts | 21 +++++++++++ .../src/routes/_protected/issues.tsx | 4 +-- .../dashboard/src/routes/_protected/pulls.tsx | 4 +-- 8 files changed, 112 insertions(+), 8 deletions(-) diff --git a/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx b/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx index 760ff1b..a125e9a 100644 --- a/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx @@ -13,9 +13,10 @@ import { Link, useRouter, } from "@tanstack/react-router"; -import type { ComponentType } from "react"; +import { type ComponentType, useEffect } from "react"; import { useShowOrgSetupQueryState } from "#/lib/github-access-dialog-query"; import { openGitHubAccessPrompt } from "#/lib/github-access-modal-store"; +import { surfaceForbiddenOrgWarnings } from "#/lib/warning-store"; type ErrorInfo = { icon: ComponentType<{ size?: number; strokeWidth?: number }>; @@ -127,6 +128,16 @@ export function DashboardErrorScreen({ error, reset }: ErrorComponentProps) { const isNotFound = action === "go-home"; const detail = isNotFound ? null : cleanErrorMessage(error.message); + useEffect(() => { + if (action !== "configure-access") return; + const msg = error.message; + const orgMatch = msg.match(/the `([^`]+)` organization/); + const orgs = orgMatch ? [orgMatch[1]] : null; + if (orgs) { + surfaceForbiddenOrgWarnings(orgs); + } + }, [action, error.message]); + return (
diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index 5cb05fe..33e9a80 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -1,13 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import { getRouteApi, Outlet } from "@tanstack/react-router"; import { motion } from "motion/react"; -import { lazy, Suspense } from "react"; +import { lazy, Suspense, useEffect } from "react"; import { githubMyIssuesQueryOptions, githubMyPullsQueryOptions, } from "#/lib/github.query"; import { useHasMounted } from "#/lib/use-has-mounted"; import { useMediaQuery } from "#/lib/use-media-query"; +import { surfaceForbiddenOrgWarnings } from "#/lib/warning-store"; import { DashboardBottomBar } from "./dashboard-bottombar"; import { DashboardMobileNav } from "./dashboard-mobile-nav"; import { @@ -50,6 +51,13 @@ export function DashboardLayout() { ...githubMyIssuesQueryOptions(scope), enabled: hasMounted, }); + useEffect(() => { + surfaceForbiddenOrgWarnings(pullsQuery.data?.forbiddenOrgs); + }, [pullsQuery.data?.forbiddenOrgs]); + useEffect(() => { + surfaceForbiddenOrgWarnings(issuesQuery.data?.forbiddenOrgs); + }, [issuesQuery.data?.forbiddenOrgs]); + const pullCount = hasMounted && pullsQuery.data ? pullsQuery.data.reviewRequested.length + diff --git a/apps/dashboard/src/lib/github-cache.ts b/apps/dashboard/src/lib/github-cache.ts index de0e940..4e85fcc 100644 --- a/apps/dashboard/src/lib/github-cache.ts +++ b/apps/dashboard/src/lib/github-cache.ts @@ -264,6 +264,18 @@ function isGitHubRateLimitError(error: unknown) { return retryAfter !== null || remaining === 0 || statusCode === 429; } +/** 30 s — short so the data refreshes once the user configures access. */ +const GITHUB_STALE_IF_FORBIDDEN_MS = 30_000; + +function isGitHubForbiddenError(error: unknown) { + const msg = error instanceof Error ? error.message : String(error ?? ""); + return ( + msg.includes("OAuth App access restrictions") || + msg.includes("FORBIDDEN") || + msg.includes("Resource not accessible by integration") + ); +} + function getRateLimitedStaleFreshUntil(currentTime: number, error: unknown) { const headers = normalizeUnknownHeaders(getErrorResponseHeaders(error)); const retryAfterSeconds = parseNullableInt(headers["retry-after"]); @@ -720,6 +732,24 @@ export async function getOrRevalidateGitHubResource({ return parseCachedPayload(existingEntry.payloadJson); } + if (existingEntry && isGitHubForbiddenError(error)) { + const staleEntry = { + ...existingEntry, + freshUntil: currentTime + GITHUB_STALE_IF_FORBIDDEN_MS, + statusCode: getErrorStatusCode(error) ?? existingEntry.statusCode, + }; + + await persistGitHubCacheEntry({ + entry: staleEntry, + legacyStore: await getResolvedStore(), + payloadStore: resolvedPayloadStore, + payloadStorageKey, + payloadRetentionSeconds, + }); + + return parseCachedPayload(existingEntry.payloadJson); + } + throw error; } diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 1ee394f..b37c7fb 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -1337,6 +1337,22 @@ async function executeGitHubGraphQL( ); } +/** + * Extract an organization name from a GitHub FORBIDDEN/OAuth App access + * restriction error. Returns `null` when the error is unrelated. + */ +function extractForbiddenOrg(error: unknown): string | null { + const message = error instanceof Error ? error.message : String(error ?? ""); + if ( + !message.includes("OAuth App access restrictions") && + !message.includes("FORBIDDEN") + ) { + return null; + } + const match = message.match(/the `([^`]+)` organization/); + return match?.[1] ?? null; +} + async function safeCommandPaletteSearch({ fallback, label, @@ -3964,6 +3980,7 @@ async function getMyPullsResult({ const sources = await getMySearchSources(context, username, deadlineAt); const results: MyPullsResult[] = []; const rateLimits: GitHubGraphQLRateLimit[] = []; + const forbiddenOrgs: string[] = []; for (const source of sources) { const sourceTimeoutMs = getRemainingSearchTimeoutMs( @@ -4101,12 +4118,19 @@ async function getMyPullsResult({ source.label, error, ); + const org = extractForbiddenOrg(error); + if (org) forbiddenOrgs.push(org); } } + const data = mergeMyPullsResults(results); + if (forbiddenOrgs.length > 0) { + data.forbiddenOrgs = [...new Set(forbiddenOrgs)]; + } + return { kind: "success", - data: mergeMyPullsResults(results), + data, metadata: createGraphQLResponseMetadata( mergeGraphQLRateLimits(rateLimits), ), @@ -4135,6 +4159,7 @@ async function getMyIssuesResult({ const sources = await getMySearchSources(context, username, deadlineAt); const results: MyIssuesResult[] = []; const rateLimits: GitHubGraphQLRateLimit[] = []; + const forbiddenOrgs: string[] = []; for (const source of sources) { const sourceTimeoutMs = getRemainingSearchTimeoutMs( @@ -4247,12 +4272,19 @@ async function getMyIssuesResult({ source.label, error, ); + const org = extractForbiddenOrg(error); + if (org) forbiddenOrgs.push(org); } } + const data = mergeMyIssuesResults(results); + if (forbiddenOrgs.length > 0) { + data.forbiddenOrgs = [...new Set(forbiddenOrgs)]; + } + return { kind: "success", - data: mergeMyIssuesResults(results), + data, metadata: createGraphQLResponseMetadata( mergeGraphQLRateLimits(rateLimits), ), diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index a0fbe49..e31fe5c 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -113,12 +113,14 @@ export type MyPullsResult = { authored: PullSummary[]; mentioned: PullSummary[]; involved: PullSummary[]; + forbiddenOrgs?: string[]; }; export type MyIssuesResult = { assigned: IssueSummary[]; authored: IssueSummary[]; mentioned: IssueSummary[]; + forbiddenOrgs?: string[]; }; export type CommandPaletteSearchResult = { diff --git a/apps/dashboard/src/lib/warning-store.ts b/apps/dashboard/src/lib/warning-store.ts index 18fdf62..69e13e6 100644 --- a/apps/dashboard/src/lib/warning-store.ts +++ b/apps/dashboard/src/lib/warning-store.ts @@ -54,6 +54,27 @@ export function useWarnings() { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } +/** + * Surface warnings for organizations that returned FORBIDDEN due to + * OAuth App access restrictions during search queries. + */ +export function surfaceForbiddenOrgWarnings(orgs: string[] | undefined) { + if (!orgs || orgs.length === 0) return; + + for (const org of orgs) { + addWarning({ + id: `forbidden-org:${org}`, + message: `The ${org} organization has restricted third-party access. Configure access to include its repositories.`, + dismissible: true, + action: { + kind: "github-access", + label: "Configure access", + owner: org, + }, + }); + } +} + /** * Check a MutationResult for permission errors and surface a warning. * Call this client-side after a mutation returns. diff --git a/apps/dashboard/src/routes/_protected/issues.tsx b/apps/dashboard/src/routes/_protected/issues.tsx index 7b15bfa..e6ad3a0 100644 --- a/apps/dashboard/src/routes/_protected/issues.tsx +++ b/apps/dashboard/src/routes/_protected/issues.tsx @@ -22,7 +22,7 @@ import { import { IssueRow } from "#/components/issues/issue-row"; import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; import { githubMyIssuesQueryOptions } from "#/lib/github.query"; -import type { IssueSummary, MyIssuesResult } from "#/lib/github.types"; +import type { IssueSummary } from "#/lib/github.types"; import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; @@ -149,7 +149,7 @@ type IssueGroupData = { id: string; title: string; icon: ComponentType<{ size?: number; strokeWidth?: number }>; - issues: MyIssuesResult[keyof MyIssuesResult]; + issues: IssueSummary[]; }; const ISSUE_GROUP_STICKY_TOP = -32; diff --git a/apps/dashboard/src/routes/_protected/pulls.tsx b/apps/dashboard/src/routes/_protected/pulls.tsx index 91f631b..b047c13 100644 --- a/apps/dashboard/src/routes/_protected/pulls.tsx +++ b/apps/dashboard/src/routes/_protected/pulls.tsx @@ -28,7 +28,7 @@ import { import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading"; import { PullRequestRow } from "#/components/pulls/pull-request-row"; import { githubMyPullsQueryOptions } from "#/lib/github.query"; -import type { MyPullsResult, PullSummary } from "#/lib/github.types"; +import type { PullSummary } from "#/lib/github.types"; import { buildSeo, formatPageTitle } from "#/lib/seo"; import { useHasMounted } from "#/lib/use-has-mounted"; @@ -180,7 +180,7 @@ type PullGroupData = { id: string; title: string; icon: ComponentType<{ size?: number; strokeWidth?: number }>; - pulls: MyPullsResult[keyof MyPullsResult]; + pulls: PullSummary[]; }; const PULL_GROUP_STICKY_TOP = -32;