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.
+
+
+ )}
+
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}