From d5b9cd9da480926eafe36f6b75b39effeb86edb4 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 18 Apr 2026 11:01:31 -0400 Subject: [PATCH 1/3] fix(dashboard): redirect to setup when GitHub App needs re-approval Verify the app user token with GET /user/installations during setup checks. Normalize installation-token mint failures and common OAuth errors so the error screen can offer /setup. Clear the protected-route auth cache when visiting setup or choosing Review GitHub access. --- .../layouts/dashboard-error-screen.tsx | 36 +++++++- apps/dashboard/src/lib/github-auth-errors.ts | 83 +++++++++++++++++++ apps/dashboard/src/lib/github.functions.ts | 25 +++++- apps/dashboard/src/lib/github.server.ts | 19 ++++- .../dashboard/src/lib/protected-auth-cache.ts | 35 ++++++++ apps/dashboard/src/routes/_protected.tsx | 13 ++- apps/dashboard/src/routes/setup.tsx | 2 + 7 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 apps/dashboard/src/lib/github-auth-errors.ts create mode 100644 apps/dashboard/src/lib/protected-auth-cache.ts diff --git a/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx b/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx index a125e9a..ab89276 100644 --- a/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-error-screen.tsx @@ -16,6 +16,7 @@ import { import { type ComponentType, useEffect } from "react"; import { useShowOrgSetupQueryState } from "#/lib/github-access-dialog-query"; import { openGitHubAccessPrompt } from "#/lib/github-access-modal-store"; +import { clearProtectedRouteCachedAuth } from "#/lib/protected-auth-cache"; import { surfaceForbiddenOrgWarnings } from "#/lib/warning-store"; type ErrorInfo = { @@ -23,7 +24,7 @@ type ErrorInfo = { iconClassName: string; title: string; description: string; - action: "retry" | "configure-access" | "go-home"; + action: "retry" | "configure-access" | "go-home" | "reauthorize-github-app"; }; function getErrorInfo(error: Error): ErrorInfo { @@ -41,6 +42,21 @@ function getErrorInfo(error: Error): ErrorInfo { }; } + if ( + lower.includes("bad credentials") || + lower.includes("docs.github.com/rest") || + /\b401\b/.test(lower) + ) { + return { + icon: LockIcon, + iconClassName: "bg-amber-500/10 text-amber-500", + title: "GitHub access needs review", + description: + "Approve DiffKit again on GitHub — for example after the app’s permissions or credentials changed.", + action: "reauthorize-github-app", + }; + } + if ( lower.includes("403") || lower.includes("forbidden") || @@ -169,6 +185,9 @@ export function DashboardErrorScreen({ error, reset }: ErrorComponentProps) {
{action === "configure-access" ? : null} + {action === "reauthorize-github-app" ? ( + + ) : null} {action === "go-home" ? ( ); } + +function ReauthorizeGitHubAppButton() { + return ( + + ); +} diff --git a/apps/dashboard/src/lib/github-auth-errors.ts b/apps/dashboard/src/lib/github-auth-errors.ts new file mode 100644 index 0000000..80e4dbf --- /dev/null +++ b/apps/dashboard/src/lib/github-auth-errors.ts @@ -0,0 +1,83 @@ +import { RequestError } from "octokit"; + +function stringifyGitHubApiMessage(data: unknown): string { + if (!data || typeof data !== "object") { + return ""; + } + + const record = data as Record; + const message = record.message; + return typeof message === "string" ? message : ""; +} + +export function compactGitHubErrorMessage(error: RequestError): string { + const bodyMessage = stringifyGitHubApiMessage(error.response?.data); + return `${error.message} ${bodyMessage}`.trim(); +} + +/** + * GitHub returns 401 "Bad credentials" (and related OAuth errors) when the + * GitHub App user-to-server token, OAuth client credentials, or JWT/app key + * material no longer matches what GitHub expects — including after permission + * changes that require the account owner to approve the installation again. + */ +export function shouldReauthorizeGitHubApp(error: unknown): boolean { + if (error instanceof RequestError) { + const status = error.status; + const combined = compactGitHubErrorMessage(error).toLowerCase(); + + if (status === 401) { + return true; + } + + if (status === 403) { + // Keep resource-scope 403s on the existing "configure access" path. + if (combined.includes("not accessible by integration")) { + return false; + } + + if ( + combined.includes("suspended") || + combined.includes("new permissions") || + combined.includes("additional permissions") || + combined.includes("must be granted") || + (combined.includes("permission") && combined.includes("pending")) + ) { + return true; + } + } + + if ( + status === 422 && + combined.includes("installation") && + (combined.includes("suspend") || combined.includes("permission")) + ) { + return true; + } + } + + if (error instanceof Error) { + const message = error.message.toLowerCase(); + if (message.includes("bad credentials")) { + return true; + } + + if (message.includes("docs.github.com/rest")) { + return true; + } + + if (message.includes("github app user token request failed")) { + if ( + message.includes("incorrect_client_credentials") || + message.includes("bad_refresh_token") || + message.includes("invalid_grant") || + message.includes("refresh_token") || + message.includes("expired") + ) { + return true; + } + } + } + + return false; +} diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index e7a55d7..6c557b4 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -65,6 +65,7 @@ import { type GitHubOrganization, isRepoVisibleWithInstallationAccess, } from "./github-access"; +import { shouldReauthorizeGitHubApp } from "./github-auth-errors"; import { getGitHubAppSlug } from "./github-app.server"; import { bumpGitHubCacheNamespaces, @@ -5056,7 +5057,29 @@ export const checkSetupComplete = createServerFn({ } const { hasGitHubAppUserAccount } = await import("./github-app.server"); - return hasGitHubAppUserAccount(session.user.id); + if (!(await hasGitHubAppUserAccount(session.user.id))) { + return false; + } + + const { getGitHubAppUserClientByUserId } = await import("./auth-runtime"); + try { + const appUserOctokit = await getGitHubAppUserClientByUserId( + session.user.id, + ); + if (!appUserOctokit) { + return false; + } + + await appUserOctokit.request("GET /user/installations", { + per_page: 1, + }); + return true; + } catch (error) { + if (shouldReauthorizeGitHubApp(error)) { + return false; + } + throw error; + } }); export const getGitHubAppAccessState = createServerFn({ diff --git a/apps/dashboard/src/lib/github.server.ts b/apps/dashboard/src/lib/github.server.ts index 1f7984d..8aeb476 100644 --- a/apps/dashboard/src/lib/github.server.ts +++ b/apps/dashboard/src/lib/github.server.ts @@ -5,6 +5,7 @@ import { getGitHubAppId, getGitHubAppPrivateKey, } from "./github-app.server"; +import { shouldReauthorizeGitHubApp } from "./github-auth-errors"; import { configureGitHubRequestPolicies } from "./github-request-policy"; const GITHUB_CLIENT_USER_AGENT = "diffkit-dashboard"; @@ -193,10 +194,20 @@ async function mintGitHubInstallationToken( tokenLabel: `app-auth:installation:${installationId}`, }); - const auth = (await app.octokit.auth({ - type: "installation", - installationId, - })) as GitHubInstallationAuthResult; + let auth: GitHubInstallationAuthResult; + try { + auth = (await app.octokit.auth({ + type: "installation", + installationId, + })) as GitHubInstallationAuthResult; + } catch (error) { + if (shouldReauthorizeGitHubApp(error)) { + throw new Error("Bad credentials - https://docs.github.com/rest", { + cause: error, + }); + } + throw error; + } if (!auth.token || !auth.expiresAt) { throw new Error( diff --git a/apps/dashboard/src/lib/protected-auth-cache.ts b/apps/dashboard/src/lib/protected-auth-cache.ts new file mode 100644 index 0000000..5a3b8a4 --- /dev/null +++ b/apps/dashboard/src/lib/protected-auth-cache.ts @@ -0,0 +1,35 @@ +export type ProtectedRouteCachedAuth = { + user: { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + emailVerified?: boolean; + createdAt?: Date; + updatedAt?: Date; + }; + session: { + id: string; + userId?: string; + expiresAt?: Date; + token?: string; + createdAt?: Date; + updatedAt?: Date; + }; +}; + +let cachedAuth: ProtectedRouteCachedAuth | null = null; + +export function getProtectedRouteCachedAuth(): ProtectedRouteCachedAuth | null { + return cachedAuth; +} + +export function setProtectedRouteCachedAuth( + next: ProtectedRouteCachedAuth, +): void { + cachedAuth = next; +} + +export function clearProtectedRouteCachedAuth(): void { + cachedAuth = null; +} diff --git a/apps/dashboard/src/routes/_protected.tsx b/apps/dashboard/src/routes/_protected.tsx index 6295aa2..90162e5 100644 --- a/apps/dashboard/src/routes/_protected.tsx +++ b/apps/dashboard/src/routes/_protected.tsx @@ -3,6 +3,11 @@ import { DashboardLayout } from "#/components/layouts/dashboard-layout"; import { ErrorScreen } from "#/components/layouts/error-screen"; import { getSession } from "#/lib/auth.functions"; import { checkSetupComplete } from "#/lib/github.functions"; +import { + clearProtectedRouteCachedAuth, + getProtectedRouteCachedAuth, + setProtectedRouteCachedAuth, +} from "#/lib/protected-auth-cache"; import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; /** @@ -10,10 +15,9 @@ import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; * The cache is cleared on full page reloads. If the session expires mid-use, * API calls in child routes will 401 and the error boundary handles it. */ -let cachedAuth: Awaited> | null = null; - export const Route = createFileRoute("/_protected")({ beforeLoad: async ({ location }) => { + const cachedAuth = getProtectedRouteCachedAuth(); if (cachedAuth) return cachedAuth; const [session, setupComplete] = await Promise.all([ @@ -32,8 +36,9 @@ export const Route = createFileRoute("/_protected")({ throw redirect({ to: "/setup" }); } - cachedAuth = { user: session.user, session: session.session }; - return cachedAuth; + const next = { user: session.user, session: session.session }; + setProtectedRouteCachedAuth(next); + return next; }, headers: () => PRIVATE_ROUTE_HEADERS, head: ({ match }) => { diff --git a/apps/dashboard/src/routes/setup.tsx b/apps/dashboard/src/routes/setup.tsx index cdf4275..3cc4b0b 100644 --- a/apps/dashboard/src/routes/setup.tsx +++ b/apps/dashboard/src/routes/setup.tsx @@ -10,6 +10,7 @@ import { type GitHubAppAccessState, getAccessHrefForOwner, } from "#/lib/github-access"; +import { clearProtectedRouteCachedAuth } from "#/lib/protected-auth-cache"; import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; import { useRefreshOnReturn } from "#/lib/use-refresh-on-return"; @@ -26,6 +27,7 @@ function SetupPageLoading() { export const Route = createFileRoute("/setup")({ pendingComponent: SetupPageLoading, beforeLoad: async () => { + clearProtectedRouteCachedAuth(); const session = await getSession(); if (!session) { throw redirect({ to: "/login", search: { redirect: "/setup" } }); From ff9a8bd57536fc2599ad1de6212508efc06c1189 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 18 Apr 2026 11:06:52 -0400 Subject: [PATCH 2/3] fix(dashboard): align protected auth cache types with session shell --- .../dashboard/src/lib/protected-auth-cache.ts | 6 ++---- apps/dashboard/src/routes/_protected.tsx | 20 +++++++++++++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/lib/protected-auth-cache.ts b/apps/dashboard/src/lib/protected-auth-cache.ts index 5a3b8a4..cf08a7b 100644 --- a/apps/dashboard/src/lib/protected-auth-cache.ts +++ b/apps/dashboard/src/lib/protected-auth-cache.ts @@ -1,12 +1,10 @@ +/** Matches dashboard shell components (e.g. topbar) route context expectations. */ export type ProtectedRouteCachedAuth = { user: { id: string; name?: string | null; - email?: string | null; + email: string; image?: string | null; - emailVerified?: boolean; - createdAt?: Date; - updatedAt?: Date; }; session: { id: string; diff --git a/apps/dashboard/src/routes/_protected.tsx b/apps/dashboard/src/routes/_protected.tsx index 90162e5..8da673d 100644 --- a/apps/dashboard/src/routes/_protected.tsx +++ b/apps/dashboard/src/routes/_protected.tsx @@ -4,8 +4,8 @@ import { ErrorScreen } from "#/components/layouts/error-screen"; import { getSession } from "#/lib/auth.functions"; import { checkSetupComplete } from "#/lib/github.functions"; import { - clearProtectedRouteCachedAuth, getProtectedRouteCachedAuth, + type ProtectedRouteCachedAuth, setProtectedRouteCachedAuth, } from "#/lib/protected-auth-cache"; import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; @@ -36,7 +36,23 @@ export const Route = createFileRoute("/_protected")({ throw redirect({ to: "/setup" }); } - const next = { user: session.user, session: session.session }; + const email = session.user.email; + if (typeof email !== "string" || email.length === 0) { + throw redirect({ + to: "/login", + search: { redirect: location.href }, + }); + } + + const next: ProtectedRouteCachedAuth = { + user: { + id: session.user.id, + name: session.user.name, + email, + image: session.user.image, + }, + session: session.session, + }; setProtectedRouteCachedAuth(next); return next; }, From 8a62da538d32d097731920fe7d8b6a5bda4274d2 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:17:06 +0000 Subject: [PATCH 3/3] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- apps/dashboard/src/lib/protected-auth-cache.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/lib/protected-auth-cache.ts b/apps/dashboard/src/lib/protected-auth-cache.ts index cf08a7b..ca83a83 100644 --- a/apps/dashboard/src/lib/protected-auth-cache.ts +++ b/apps/dashboard/src/lib/protected-auth-cache.ts @@ -19,15 +19,24 @@ export type ProtectedRouteCachedAuth = { let cachedAuth: ProtectedRouteCachedAuth | null = null; export function getProtectedRouteCachedAuth(): ProtectedRouteCachedAuth | null { + if (typeof window === "undefined") { + return null; + } return cachedAuth; } export function setProtectedRouteCachedAuth( next: ProtectedRouteCachedAuth, ): void { + if (typeof window === "undefined") { + return; + } cachedAuth = next; } export function clearProtectedRouteCachedAuth(): void { + if (typeof window === "undefined") { + return; + } cachedAuth = null; -} +} \ No newline at end of file