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..ca83a83
--- /dev/null
+++ b/apps/dashboard/src/lib/protected-auth-cache.ts
@@ -0,0 +1,42 @@
+/** Matches dashboard shell components (e.g. topbar) route context expectations. */
+export type ProtectedRouteCachedAuth = {
+ user: {
+ id: string;
+ name?: string | null;
+ email: string;
+ image?: string | null;
+ };
+ session: {
+ id: string;
+ userId?: string;
+ expiresAt?: Date;
+ token?: string;
+ createdAt?: Date;
+ updatedAt?: Date;
+ };
+};
+
+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
diff --git a/apps/dashboard/src/routes/_protected.tsx b/apps/dashboard/src/routes/_protected.tsx
index 6295aa2..8da673d 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 {
+ getProtectedRouteCachedAuth,
+ type ProtectedRouteCachedAuth,
+ 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,25 @@ export const Route = createFileRoute("/_protected")({
throw redirect({ to: "/setup" });
}
- cachedAuth = { user: session.user, session: session.session };
- return cachedAuth;
+ 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;
},
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" } });