Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion apps/dashboard/src/components/layouts/dashboard-error-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ 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 = {
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
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 {
Expand All @@ -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") ||
Expand Down Expand Up @@ -169,6 +185,9 @@ export function DashboardErrorScreen({ error, reset }: ErrorComponentProps) {

<div className="flex items-center gap-2">
{action === "configure-access" ? <ConfigureAccessButton /> : null}
{action === "reauthorize-github-app" ? (
<ReauthorizeGitHubAppButton />
) : null}
{action === "go-home" ? (
<Button variant="ghost" size="sm" asChild>
<Link to="/">Go home</Link>
Expand Down Expand Up @@ -207,3 +226,18 @@ function ConfigureAccessButton() {
</Button>
);
}

function ReauthorizeGitHubAppButton() {
return (
<Button size="sm" asChild>
<Link
to="/setup"
onClick={() => {
clearProtectedRouteCachedAuth();
}}
>
Review GitHub access
</Link>
</Button>
);
}
83 changes: 83 additions & 0 deletions apps/dashboard/src/lib/github-auth-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { RequestError } from "octokit";

function stringifyGitHubApiMessage(data: unknown): string {
if (!data || typeof data !== "object") {
return "";
}

const record = data as Record<string, unknown>;
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;
}
25 changes: 24 additions & 1 deletion apps/dashboard/src/lib/github.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
19 changes: 15 additions & 4 deletions apps/dashboard/src/lib/github.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
42 changes: 42 additions & 0 deletions apps/dashboard/src/lib/protected-auth-cache.ts
Original file line number Diff line number Diff line change
@@ -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;
}
29 changes: 25 additions & 4 deletions apps/dashboard/src/routes/_protected.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ 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";

/**
* Cache the auth check so navigations within the dashboard are instant.
* 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<ReturnType<typeof getSession>> | null = null;

export const Route = createFileRoute("/_protected")({
beforeLoad: async ({ location }) => {
const cachedAuth = getProtectedRouteCachedAuth();
if (cachedAuth) return cachedAuth;

const [session, setupComplete] = await Promise.all([
Expand 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 }) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/routes/setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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" } });
Expand Down
Loading