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
3 changes: 2 additions & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
},
"dependencies": {
"@cloudflare/vite-plugin": "^1.26.0",
"@pierre/diffs": "^1.1.12",
"@diffkit/icons": "workspace:*",
"@diffkit/ui": "workspace:*",
"@pierre/diffs": "^1.1.12",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "latest",
"@tanstack/react-query": "latest",
Expand All @@ -37,6 +37,7 @@
"better-auth": "^1.6.0",
"drizzle-orm": "^0.45.2",
"next-themes": "^0.4.6",
"nuqs": "^2.8.9",
"octokit": "^5.0.5",
"react": "^19.2.0",
"react-dom": "^19.2.0",
Expand Down
45 changes: 35 additions & 10 deletions apps/dashboard/src/components/layouts/dashboard-bottombar.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { AlertCircleIcon, XIcon } from "@diffkit/icons";
import { cn } from "@diffkit/ui/lib/utils";
import { useShowOrgSetupQueryState } from "#/lib/github-access-dialog-query";
import { openGitHubAccessPrompt } from "#/lib/github-access-modal-store";
import { removeWarning, useWarnings } from "#/lib/warning-store";

export function DashboardBottomBar() {
const warnings = useWarnings();
const [, setShowOrgSetup] = useShowOrgSetupQueryState();

if (warnings.length === 0) return null;

Expand All @@ -18,16 +21,38 @@ export function DashboardBottomBar() {
>
<AlertCircleIcon size={14} strokeWidth={2} className="shrink-0" />
<span className="min-w-0 flex-1">{warning.message}</span>
{warning.action && (
<a
href={warning.action.href}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded-md bg-yellow-950/10 px-2 py-0.5 font-medium transition-colors hover:bg-yellow-950/20"
>
{warning.action.label}
</a>
)}
{warning.action
? (() => {
const action = warning.action;

return action.kind === "link" ? (
<a
href={action.href}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 rounded-md bg-yellow-950/10 px-2 py-0.5 font-medium transition-colors hover:bg-yellow-950/20"
>
{action.label}
</a>
) : (
<button
type="button"
onClick={() => {
openGitHubAccessPrompt({
source: "warning",
owner: action.owner,
repo: action.repo,
fallbackHref: action.href,
});
void setShowOrgSetup(true);
}}
className="shrink-0 rounded-md bg-yellow-950/10 px-2 py-0.5 font-medium transition-colors hover:bg-yellow-950/20"
>
{action.label}
</button>
);
})()
: null}
{warning.dismissible && (
<button
type="button"
Expand Down
4 changes: 3 additions & 1 deletion apps/dashboard/src/components/layouts/dashboard-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useGitHubRevalidation } from "#/lib/use-github-revalidation";
import { useHasMounted } from "#/lib/use-has-mounted";
import { DashboardBottomBar } from "./dashboard-bottombar";
import { DashboardTopbar } from "./dashboard-topbar";
import { GitHubAccessDialog } from "./github-access-dialog";

const routeApi = getRouteApi("/_protected");

Expand Down Expand Up @@ -41,7 +42,7 @@ export function DashboardLayout() {
const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data);

return (
<div className="flex h-dvh flex-col bg-muted">
<div className="isolate flex h-dvh flex-col bg-muted">
<DashboardTopbar
user={user}
tabsReady={tabsReady}
Expand All @@ -60,6 +61,7 @@ export function DashboardLayout() {
</div>
<DashboardBottomBar />
<CommandPalette />
<GitHubAccessDialog userId={user.id} />
</div>
);
}
277 changes: 277 additions & 0 deletions apps/dashboard/src/components/layouts/github-access-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
"use client";

import { Badge } from "@diffkit/ui/components/badge";
import { Button } from "@diffkit/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@diffkit/ui/components/dialog";
import { cn } from "@diffkit/ui/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { getGitHubAppAccessState } from "#/lib/github.functions";
import {
findInstallationForOwner,
type GitHubAppAccessState,
getAccessHrefForOwner,
} from "#/lib/github-access";
import { useShowOrgSetupQueryState } from "#/lib/github-access-dialog-query";
import {
closeGitHubAccessPrompt,
useGitHubAccessPrompt,
} from "#/lib/github-access-modal-store";
import { useHasMounted } from "#/lib/use-has-mounted";

const ONBOARDING_STORAGE_KEY_PREFIX = "diffkit:github-access-onboarding:v1:";

function getOnboardingStorageKey(userId: string) {
return `${ONBOARDING_STORAGE_KEY_PREFIX}${userId}`;
}

function dismissOnboarding(userId: string) {
window.localStorage.setItem(getOnboardingStorageKey(userId), "dismissed");
}

function isOnboardingDismissed(userId: string) {
return (
window.localStorage.getItem(getOnboardingStorageKey(userId)) === "dismissed"
);
}

export function GitHubAccessDialog({ userId }: { userId: string }) {
const hasMounted = useHasMounted();
const prompt = useGitHubAccessPrompt();
const [onboardingOpen, setOnboardingOpen] = useState(false);
const [showOrgSetup, setShowOrgSetup] = useShowOrgSetupQueryState();

useEffect(() => {
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],
queryFn: () => getGitHubAppAccessState(),
enabled: hasMounted && isOpen,
staleTime: 5 * 60 * 1000,
});

const state = accessQuery.data;
const highlightedOwner = prompt?.owner ?? null;
const highlightedHref = getAccessHrefForOwner(
state,
highlightedOwner,
prompt?.fallbackHref,
);

function handleOpenChange(nextOpen: boolean) {
if (nextOpen) {
return;
}

void setShowOrgSetup(false);
closeGitHubAccessPrompt();
if (onboardingOpen) {
dismissOnboarding(userId);
setOnboardingOpen(false);
}
}

const title = prompt?.repo
? `Configure access for ${prompt.repo}`
: "GitHub access";
const description = prompt?.repo
? `DiffKit needs access to this repository.`
: "Configure the accounts DiffKit can access.";
const primaryHref =
highlightedHref ?? state?.publicInstallUrl ?? prompt?.fallbackHref ?? null;

return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="top-[14%] translate-y-0 gap-0 p-0 sm:max-w-lg">
<DialogHeader className="gap-1.5 px-5 pt-5 pb-4">
<DialogTitle className="text-[0.9375rem]">{title}</DialogTitle>
<DialogDescription className="text-[0.8125rem]">
{description}
</DialogDescription>
</DialogHeader>

<div className="px-5 pb-5">
{accessQuery.isPending ? (
<div className="rounded-xl border border-border/70 px-4 py-8">
<p className="text-center text-sm text-muted-foreground">
Loading installations…
</p>
</div>
) : accessQuery.isError || !state ? (
<div className="rounded-xl border border-border/70 px-4 py-8">
<p className="text-center text-sm text-muted-foreground">
Could not load installations. You can still continue into
GitHub.
</p>
</div>
) : (
<AccessList state={state} highlightedOwner={highlightedOwner} />
)}
</div>

<DialogFooter className="border-t border-border/70 px-5 py-3.5">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleOpenChange(false)}
>
Close
</Button>
{primaryHref ? (
<Button asChild size="sm">
<a href={primaryHref} target="_blank" rel="noopener noreferrer">
Configure access
</a>
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

type AccessTarget = {
login: string;
type: "personal" | "org";
installed: boolean;
scope: "all" | "selected" | null;
href: string | null;
isHighlighted: boolean;
};

function buildTargets(
state: GitHubAppAccessState,
highlightedOwner: string | null,
): AccessTarget[] {
const targets: AccessTarget[] = [];

targets.push({
login: state.viewerLogin,
type: "personal",
installed: !!state.personalInstallation,
scope: state.personalInstallation
? state.personalInstallation.repositorySelection === "selected"
? "selected"
: "all"
: null,
href: getAccessHrefForOwner(state, state.viewerLogin),
isHighlighted:
highlightedOwner?.toLowerCase() === state.viewerLogin.toLowerCase(),
});

for (const org of state.organizations) {
const installation = findInstallationForOwner(state, org.login);
targets.push({
login: org.login,
type: "org",
installed: !!installation,
scope: installation
? installation.repositorySelection === "selected"
? "selected"
: "all"
: null,
href: getAccessHrefForOwner(state, org.login),
isHighlighted:
highlightedOwner?.toLowerCase() === org.login.toLowerCase(),
});
}

return targets;
}

function AccessList({
state,
highlightedOwner,
}: {
state: GitHubAppAccessState;
highlightedOwner: string | null;
}) {
const targets = buildTargets(state, highlightedOwner);

return (
<ul className="overflow-hidden rounded-xl border border-border/70">
{targets.map((target) => (
<li
key={target.login}
className={cn(
"flex items-center gap-3 px-3.5 py-2.5",
"not-first:border-t not-first:border-border/70",
target.isHighlighted && "bg-accent/55",
)}
>
<StatusDot installed={target.installed} />

<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{target.login}</p>
{target.scope ? (
<Badge
variant="secondary"
className="rounded-md px-1.5 py-0 text-[0.625rem]"
>
{target.scope === "selected" ? "Selected repos" : "All repos"}
</Badge>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{target.installed
? target.scope === "selected"
? "Installed · selected repositories"
: "Installed"
: "Not installed"}
{target.type === "personal" ? " · personal" : " · org"}
</p>
</div>

{target.href ? (
<Button
asChild
variant={target.installed ? "secondary" : "outline"}
size="xs"
className="shrink-0"
>
<a href={target.href} target="_blank" rel="noopener noreferrer">
{target.installed ? "Manage" : "Install"}
</a>
</Button>
) : null}
</li>
))}

{targets.length === 1 && (
<li className="border-t border-border/70 px-3.5 py-6">
<p className="text-center text-xs text-muted-foreground">
No organizations detected on this account.
</p>
</li>
)}
</ul>
);
}

function StatusDot({ installed }: { installed: boolean }) {
return (
<div
className={cn(
"flex size-2 shrink-0 rounded-full",
installed ? "bg-green-500" : "bg-yellow-500",
)}
/>
);
}
9 changes: 9 additions & 0 deletions apps/dashboard/src/lib/github-access-dialog-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { parseAsBoolean, useQueryState } from "nuqs";

export const showOrgSetupParser = parseAsBoolean
.withDefault(false)
.withOptions({ history: "replace" });

export function useShowOrgSetupQueryState() {
return useQueryState("show-org-setup", showOrgSetupParser);
}
Loading
Loading