From 5b95f442c4bb3876e4e52baea8bdb3b6aa21e616 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sat, 11 Apr 2026 18:16:33 -0400 Subject: [PATCH] Add /setup page and guard protected routes until GitHub App is authorized (#72) Prevent data loading before users complete GitHub App setup by redirecting from protected routes to a dedicated /setup page. Also hardens getGitHubContext with try/catch to avoid Worker crashes from missing OAuth tokens. --- .../components/layouts/dashboard-layout.tsx | 29 +-- .../layouts/github-access-dialog.tsx | 31 --- apps/dashboard/src/lib/github-access.ts | 4 +- apps/dashboard/src/lib/github-app.server.ts | 5 + apps/dashboard/src/lib/github.functions.ts | 26 ++- apps/dashboard/src/routeTree.gen.ts | 21 ++ apps/dashboard/src/routes/_protected.tsx | 7 + .../src/routes/api/github/app/authorize.ts | 2 +- .../src/routes/api/github/app/callback.ts | 2 +- apps/dashboard/src/routes/setup.tsx | 221 ++++++++++++++++++ 10 files changed, 280 insertions(+), 68 deletions(-) create mode 100644 apps/dashboard/src/routes/setup.tsx diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index fbb326b..81f93ee 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -1,13 +1,10 @@ import { useQuery } from "@tanstack/react-query"; import { getRouteApi, Outlet } from "@tanstack/react-router"; -import { lazy, Suspense, useEffect, useRef } from "react"; -import { getGitHubAppAccessState } from "#/lib/github.functions"; +import { lazy, Suspense } from "react"; import { githubMyIssuesQueryOptions, githubMyPullsQueryOptions, } from "#/lib/github.query"; -import { useShowOrgSetupQueryState } from "#/lib/github-access-dialog-query"; -import { openGitHubAccessPrompt } from "#/lib/github-access-modal-store"; import { useGitHubRevalidation } from "#/lib/use-github-revalidation"; import { useHasMounted } from "#/lib/use-has-mounted"; import { DashboardBottomBar } from "./dashboard-bottombar"; @@ -31,16 +28,8 @@ export function DashboardLayout() { const { user } = routeApi.useRouteContext(); const scope = { userId: user.id }; const hasMounted = useHasMounted(); - const missingAppAuthPromptedRef = useRef(false); - const [showOrgSetup, setShowOrgSetup] = useShowOrgSetupQueryState(); useGitHubRevalidation(user.id); - const githubAccessQuery = useQuery({ - queryKey: ["github-app-access-state", user.id], - queryFn: () => getGitHubAppAccessState(), - enabled: hasMounted, - staleTime: 5 * 60 * 1000, - }); const pullsQuery = useQuery({ ...githubMyPullsQueryOptions(scope), enabled: hasMounted, @@ -65,22 +54,6 @@ export function DashboardLayout() { : undefined; const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data); - useEffect(() => { - if ( - !hasMounted || - showOrgSetup || - missingAppAuthPromptedRef.current || - !githubAccessQuery.data || - githubAccessQuery.data.installationsAvailable - ) { - return; - } - - missingAppAuthPromptedRef.current = true; - openGitHubAccessPrompt({ source: "onboarding" }); - void setShowOrgSetup(true); - }, [githubAccessQuery.data, hasMounted, setShowOrgSetup, showOrgSetup]); - return (
{ - if (!hasMounted || isOnboardingDismissed(userId)) { - return; - } - - setOnboardingOpen(true); - void setShowOrgSetup(true); - }, [hasMounted, setShowOrgSetup, userId]); - const isOpen = showOrgSetup; const accessQuery = useQuery({ queryKey: ["github-app-access-state", userId], @@ -88,10 +61,6 @@ export function GitHubAccessDialog({ userId }: { userId: string }) { void setShowOrgSetup(false); closeGitHubAccessPrompt(); - if (onboardingOpen) { - dismissOnboarding(userId); - setOnboardingOpen(false); - } } const title = prompt?.repo diff --git a/apps/dashboard/src/lib/github-access.ts b/apps/dashboard/src/lib/github-access.ts index 768b6ab..c6f66fd 100644 --- a/apps/dashboard/src/lib/github-access.ts +++ b/apps/dashboard/src/lib/github-access.ts @@ -38,9 +38,7 @@ export function buildGitHubAppInstallUrl(slug: string | null | undefined) { return slug ? `https://github.com/apps/${slug}/installations/new` : null; } -export function buildGitHubAppAuthorizePath( - returnTo = "/?show-org-setup=true", -) { +export function buildGitHubAppAuthorizePath(returnTo = "/setup") { const params = new URLSearchParams({ returnTo }); return `/api/github/app/authorize?${params.toString()}`; } diff --git a/apps/dashboard/src/lib/github-app.server.ts b/apps/dashboard/src/lib/github-app.server.ts index 4a78863..410807d 100644 --- a/apps/dashboard/src/lib/github-app.server.ts +++ b/apps/dashboard/src/lib/github-app.server.ts @@ -114,6 +114,11 @@ export async function getGitHubOAuthAccountByUserId(userId: string) { .get(); } +export async function hasGitHubAppUserAccount(userId: string) { + const row = await getGitHubAppUserAccountByUserId(userId); + return Boolean(row?.accessToken); +} + async function getGitHubAppUserAccountByUserId(userId: string) { const db = getDb(); diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 1832af5..73de41b 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -742,10 +742,15 @@ async function getGitHubContext(): Promise { } debug("github-access", "session found", { userId: session.user.id }); - return { - session, - octokit: await getGitHubClientByUserId(session.user.id), - }; + try { + return { + session, + octokit: await getGitHubClientByUserId(session.user.id), + }; + } catch (error) { + console.error("[github-access] failed to create GitHub client", error); + return null; + } }); } @@ -2708,6 +2713,19 @@ export const getGitHubViewer = createServerFn({ method: "GET" }).handler( }, ); +export const checkSetupComplete = createServerFn({ + method: "GET", +}).handler(async (): Promise => { + const { getRequestSession } = await import("./auth-runtime"); + const session = await getRequestSession(); + if (!session) { + return false; + } + + const { hasGitHubAppUserAccount } = await import("./github-app.server"); + return hasGitHubAppUserAccount(session.user.id); +}); + export const getGitHubAppAccessState = createServerFn({ method: "GET", }).handler(async (): Promise => { diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 66a1dd0..81dc162 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' +import { Route as SetupRouteImport } from './routes/setup' import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' import { Route as LoginRouteImport } from './routes/login' import { Route as ProtectedRouteImport } from './routes/_protected' @@ -30,6 +31,11 @@ const SitemapDotxmlRoute = SitemapDotxmlRouteImport.update({ path: '/sitemap.xml', getParentRoute: () => rootRouteImport, } as any) +const SetupRoute = SetupRouteImport.update({ + id: '/setup', + path: '/setup', + getParentRoute: () => rootRouteImport, +} as any) const RobotsDottxtRoute = RobotsDottxtRouteImport.update({ id: '/robots.txt', path: '/robots.txt', @@ -107,6 +113,7 @@ export interface FileRoutesByFullPath { '/': typeof ProtectedIndexRoute '/login': typeof LoginRoute '/robots.txt': typeof RobotsDottxtRoute + '/setup': typeof SetupRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute @@ -122,6 +129,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/login': typeof LoginRoute '/robots.txt': typeof RobotsDottxtRoute + '/setup': typeof SetupRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/issues': typeof ProtectedIssuesRoute '/pulls': typeof ProtectedPullsRoute @@ -140,6 +148,7 @@ export interface FileRoutesById { '/_protected': typeof ProtectedRouteWithChildren '/login': typeof LoginRoute '/robots.txt': typeof RobotsDottxtRoute + '/setup': typeof SetupRoute '/sitemap.xml': typeof SitemapDotxmlRoute '/_protected/issues': typeof ProtectedIssuesRoute '/_protected/pulls': typeof ProtectedPullsRoute @@ -159,6 +168,7 @@ export interface FileRouteTypes { | '/' | '/login' | '/robots.txt' + | '/setup' | '/sitemap.xml' | '/issues' | '/pulls' @@ -174,6 +184,7 @@ export interface FileRouteTypes { to: | '/login' | '/robots.txt' + | '/setup' | '/sitemap.xml' | '/issues' | '/pulls' @@ -191,6 +202,7 @@ export interface FileRouteTypes { | '/_protected' | '/login' | '/robots.txt' + | '/setup' | '/sitemap.xml' | '/_protected/issues' | '/_protected/pulls' @@ -209,6 +221,7 @@ export interface RootRouteChildren { ProtectedRoute: typeof ProtectedRouteWithChildren LoginRoute: typeof LoginRoute RobotsDottxtRoute: typeof RobotsDottxtRoute + SetupRoute: typeof SetupRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiWebhooksGithubRoute: typeof ApiWebhooksGithubRoute @@ -225,6 +238,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SitemapDotxmlRouteImport parentRoute: typeof rootRouteImport } + '/setup': { + id: '/setup' + path: '/setup' + fullPath: '/setup' + preLoaderRoute: typeof SetupRouteImport + parentRoute: typeof rootRouteImport + } '/robots.txt': { id: '/robots.txt' path: '/robots.txt' @@ -354,6 +374,7 @@ const rootRouteChildren: RootRouteChildren = { ProtectedRoute: ProtectedRouteWithChildren, LoginRoute: LoginRoute, RobotsDottxtRoute: RobotsDottxtRoute, + SetupRoute: SetupRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiWebhooksGithubRoute: ApiWebhooksGithubRoute, diff --git a/apps/dashboard/src/routes/_protected.tsx b/apps/dashboard/src/routes/_protected.tsx index a1d867d..826c818 100644 --- a/apps/dashboard/src/routes/_protected.tsx +++ b/apps/dashboard/src/routes/_protected.tsx @@ -2,6 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; 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 { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; export const Route = createFileRoute("/_protected")({ @@ -13,6 +14,12 @@ export const Route = createFileRoute("/_protected")({ search: { redirect: location.href }, }); } + + const setupComplete = await checkSetupComplete(); + if (!setupComplete) { + throw redirect({ to: "/setup" }); + } + return { user: session.user, session: session.session }; }, headers: () => PRIVATE_ROUTE_HEADERS, diff --git a/apps/dashboard/src/routes/api/github/app/authorize.ts b/apps/dashboard/src/routes/api/github/app/authorize.ts index 55b5cc6..21045a2 100644 --- a/apps/dashboard/src/routes/api/github/app/authorize.ts +++ b/apps/dashboard/src/routes/api/github/app/authorize.ts @@ -5,7 +5,7 @@ import { PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; const STATE_COOKIE = "github_app_oauth_state"; const RETURN_TO_COOKIE = "github_app_oauth_return_to"; -const DEFAULT_RETURN_TO = "/?show-org-setup=true"; +const DEFAULT_RETURN_TO = "/setup"; function normalizeReturnTo(value: string | null) { if (!value || !value.startsWith("/") || value.startsWith("//")) { diff --git a/apps/dashboard/src/routes/api/github/app/callback.ts b/apps/dashboard/src/routes/api/github/app/callback.ts index dc885dc..eec90c4 100644 --- a/apps/dashboard/src/routes/api/github/app/callback.ts +++ b/apps/dashboard/src/routes/api/github/app/callback.ts @@ -5,7 +5,7 @@ import { PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; const STATE_COOKIE = "github_app_oauth_state"; const RETURN_TO_COOKIE = "github_app_oauth_return_to"; -const DEFAULT_RETURN_TO = "/?show-org-setup=true"; +const DEFAULT_RETURN_TO = "/setup"; function getCookie(request: Request, name: string) { const cookieHeader = request.headers.get("cookie") ?? ""; diff --git a/apps/dashboard/src/routes/setup.tsx b/apps/dashboard/src/routes/setup.tsx new file mode 100644 index 0000000..17d78d2 --- /dev/null +++ b/apps/dashboard/src/routes/setup.tsx @@ -0,0 +1,221 @@ +import { Button } from "@diffkit/ui/components/button"; +import { Logo } from "@diffkit/ui/components/logo"; +import { createFileRoute, Link, redirect } from "@tanstack/react-router"; +import { getSession } from "#/lib/auth.functions"; +import { getGitHubAppAccessState } from "#/lib/github.functions"; +import { + buildGitHubAppAuthorizePath, + findInstallationForOwner, + type GitHubAppAccessState, + getAccessHrefForOwner, +} from "#/lib/github-access"; +import { buildSeo, formatPageTitle, PRIVATE_ROUTE_HEADERS } from "#/lib/seo"; + +export const Route = createFileRoute("/setup")({ + beforeLoad: async () => { + const session = await getSession(); + if (!session) { + throw redirect({ to: "/login", search: { redirect: "/setup" } }); + } + + return { user: session.user }; + }, + loader: async () => { + const accessState = await getGitHubAppAccessState(); + return { accessState }; + }, + headers: () => PRIVATE_ROUTE_HEADERS, + head: ({ match }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle("Setup"), + description: "Configure GitHub access for DiffKit.", + robots: "noindex", + }), + component: SetupPage, +}); + +function SetupPage() { + const { accessState: state } = Route.useLoaderData(); + + const hasInstallations = + state?.installationsAvailable === true && + (state.personalInstallation != null || state.orgInstallations.length > 0); + const allInstalled = + hasInstallations && + state.personalInstallation != null && + state.missingOrganizations.length === 0; + const needsAppAuthorization = + state != null && state.installationsAvailable === false; + const primaryHref = allInstalled + ? null + : needsAppAuthorization + ? (state.appAuthorizationUrl ?? buildGitHubAppAuthorizePath("/setup")) + : (state?.publicInstallUrl ?? null); + + return ( +
+
+
+
+ +
+

+ Connect your GitHub +

+

+ DiffKit needs access to your repositories to get started. +

+
+
+ +
+ {state ? ( + + ) : ( +
+

+ Could not load installation status. Please authorize the app + to continue. +

+
+ )} +
+ {primaryHref ? ( + + ) : !state ? ( + + ) : null} + {hasInstallations ? ( + + ) : null} +
+
+
+
+
+ ); +} + +function SetupAccessList({ state }: { state: GitHubAppAccessState }) { + const canDetect = state.installationsAvailable; + + const targets = [ + { + login: state.viewerLogin, + type: "personal" as const, + status: canDetect + ? state.personalInstallation + ? ("installed" as const) + : ("not-installed" as const) + : ("unknown" as const), + scope: state.personalInstallation + ? state.personalInstallation.repositorySelection === "selected" + ? ("selected" as const) + : ("all" as const) + : null, + href: getAccessHrefForOwner(state, state.viewerLogin), + }, + ...state.organizations.map((org) => { + const installation = findInstallationForOwner(state, org.login); + return { + login: org.login, + type: "org" as const, + status: canDetect + ? installation + ? ("installed" as const) + : ("not-installed" as const) + : ("unknown" as const), + scope: installation + ? installation.repositorySelection === "selected" + ? ("selected" as const) + : ("all" as const) + : null, + href: getAccessHrefForOwner(state, org.login), + }; + }), + ]; + + return ( +
    + {targets.map((target) => ( +
  • +
    +
    +

    {target.login}

    +

    + {target.status === "installed" + ? target.scope === "selected" + ? "Installed · selected repositories" + : "Installed" + : target.status === "not-installed" + ? "Not installed" + : "Authorize app to check status"} + {target.type === "personal" ? " · personal" : " · org"} +

    +
    + {target.href ? ( + + ) : null} +
  • + ))} + {targets.length === 1 && ( +
  • +

    + No organizations detected on this account. +

    +
  • + )} +
+ ); +}