feat(den-web): add hidden integrations page and gate plugins on connection (#1475)

Introduce a new /integrations route under the org dashboard where users
can simulate connecting GitHub or Bitbucket through a realistic OAuth-
style wizard. The Plugins page now only shows its catalog once at least
one integration is connected; connecting more providers unlocks more
plugins.

- Integrations list card per provider (GitHub, Bitbucket) with connect /
  disconnect actions, connected account chips, and repo pills.
- Multi-step connect dialog: authorize -> select account -> select repos
  -> connecting -> connected. Dismissable, fully keyboard accessible,
  matches the existing OrgLimitDialog scrim/card frame.
- Data layer uses TanStack Query mutations; connections live only in the
  React Query cache (in-memory, per user's request). Mutations
  invalidate both ['integrations', 'list'] and ['plugins'], so the
  Plugins page updates in place when you connect or disconnect.
- plugin-data: each plugin now declares requiresProvider ('any' |
  'github' | 'bitbucket'). The plugins queryFn reads the integration
  cache and filters accordingly; the catalog stays empty with zero
  connections.
- plugins-screen: new 'Connect an integration' empty state with a CTA
  that deep-links to /integrations.
- No sidebar entry added (hidden page per request). Header title is
  mapped to 'Integrations' so navigating manually still feels coherent.

All data is mocked; the same hook surface (useIntegrations / useConnect
Integration / useDisconnectIntegration / usePlugins) will back a real
API with a one-line queryFn swap later.
This commit is contained in:
ben
2026-04-17 12:05:07 -07:00
committed by GitHub
parent 88e71aa716
commit ac7e082908
8 changed files with 1030 additions and 13 deletions

View File

@@ -284,6 +284,10 @@ export function getPluginRoute(orgSlug: string, pluginId: string): string {
return `${getPluginsRoute(orgSlug)}/${encodeURIComponent(pluginId)}`;
}
export function getIntegrationsRoute(orgSlug: string): string {
return `${getOrgDashboardRoute(orgSlug)}/integrations`;
}
export function parseOrgListPayload(payload: unknown): {
orgs: DenOrgSummary[];
activeOrgId: string | null;

View File

@@ -0,0 +1,455 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import {
ArrowRight,
CheckCircle2,
GitBranch,
Lock,
ShieldCheck,
X,
} from "lucide-react";
import { DenButton, buttonVariants } from "../../../../_components/ui/button";
import { DenInput } from "../../../../_components/ui/input";
import { DenSelectableRow } from "../../../../_components/ui/selectable-row";
import {
type IntegrationAccount,
type IntegrationProvider,
type IntegrationRepo,
getProviderMeta,
useConnectIntegration,
useIntegrationAccounts,
useIntegrationRepos,
} from "./integration-data";
/**
* IntegrationConnectDialog
*
* Walks the user through a realistic OAuth-style connect flow entirely in-app:
* 1. authorize — eyebrow scopes + "Authorize" button (mocks the IdP redirect)
* 2. select_account — pick a personal account or an org/workspace
* 3. select_repos — pick one or more repos to expose
* 4. connecting — spinner while the mutation resolves
* 5. connected — success card with a "Done" CTA
*
* No real redirect to GitHub/Bitbucket — the "Authorize" step just advances
* the wizard. All progress is stateful client-side so the walkthrough feels
* real and the final React Query cache ends up in a correct state.
*/
type Step = "authorize" | "select_account" | "select_repos" | "connecting" | "connected";
const STEP_ORDER: Step[] = ["authorize", "select_account", "select_repos", "connecting", "connected"];
const STEP_LABELS: Record<Step, string> = {
authorize: "Authorize",
select_account: "Select account",
select_repos: "Select repositories",
connecting: "Connecting",
connected: "Connected",
};
export function IntegrationConnectDialog({
open,
provider,
onClose,
}: {
open: boolean;
provider: IntegrationProvider | null;
onClose: () => void;
}) {
const [step, setStep] = useState<Step>("authorize");
const [selectedAccount, setSelectedAccount] = useState<IntegrationAccount | null>(null);
const [selectedRepoIds, setSelectedRepoIds] = useState<Set<string>>(new Set());
const [repoQuery, setRepoQuery] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
const accountsQuery = useIntegrationAccounts(provider ?? "github", Boolean(provider) && step === "select_account");
const reposQuery = useIntegrationRepos(provider ?? "github", selectedAccount?.id ?? null);
const connectMutation = useConnectIntegration();
// Reset the wizard every time the dialog is re-opened.
useEffect(() => {
if (open) {
setStep("authorize");
setSelectedAccount(null);
setSelectedRepoIds(new Set());
setRepoQuery("");
setLocalError(null);
}
}, [open, provider]);
if (!open || !provider) {
return null;
}
const meta = getProviderMeta(provider);
const stepIndex = STEP_ORDER.indexOf(step);
const progressLabel =
step === "connected"
? "Done"
: `Step ${Math.min(stepIndex + 1, 4)} of 4 · ${STEP_LABELS[step]}`;
// Filtered repos for the select step.
const filteredRepos = useMemo(() => {
const repos = reposQuery.data ?? [];
const normalized = repoQuery.trim().toLowerCase();
if (!normalized) return repos;
return repos.filter(
(repo) =>
repo.fullName.toLowerCase().includes(normalized) ||
repo.description.toLowerCase().includes(normalized),
);
}, [reposQuery.data, repoQuery]);
function handleToggleRepo(repo: IntegrationRepo) {
setSelectedRepoIds((prev) => {
const next = new Set(prev);
if (next.has(repo.id)) {
next.delete(repo.id);
} else {
next.add(repo.id);
}
return next;
});
}
async function handleConnect() {
if (!selectedAccount || !provider) return;
const repos = (reposQuery.data ?? []).filter((repo) => selectedRepoIds.has(repo.id));
if (repos.length === 0) {
setLocalError("Select at least one repository to connect.");
return;
}
setLocalError(null);
setStep("connecting");
try {
await connectMutation.mutateAsync({
provider,
account: selectedAccount,
repos,
});
setStep("connected");
} catch (error) {
setLocalError(error instanceof Error ? error.message : "Failed to connect integration.");
setStep("select_repos");
}
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/45 px-4 py-6"
role="dialog"
aria-modal="true"
aria-label={`Connect ${meta.name}`}
>
<div className="relative w-full max-w-lg rounded-[28px] border border-gray-200 bg-white p-6 shadow-[0_24px_80px_-32px_rgba(15,23,42,0.45)]">
{/* Close */}
<button
type="button"
onClick={onClose}
className="absolute right-5 top-5 inline-flex h-8 w-8 items-center justify-center rounded-full text-gray-400 transition hover:bg-gray-100 hover:text-gray-700"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
{/* Header */}
<div className="grid gap-2 pr-8">
<p className="text-[12px] font-semibold uppercase tracking-[0.18em] text-gray-400">
{progressLabel}
</p>
<div className="flex items-center gap-3">
<ProviderBadge provider={provider} />
<div>
<h2 className="text-[20px] font-semibold tracking-[-0.03em] text-gray-950">
Connect {meta.name}
</h2>
<p className="text-[13px] text-gray-500">{meta.description}</p>
</div>
</div>
</div>
{/* Body */}
<div className="mt-6">
{step === "authorize" ? (
<AuthorizeStep scopes={meta.scopes} providerName={meta.name} />
) : step === "select_account" ? (
<SelectAccountStep
accounts={accountsQuery.data ?? []}
loading={accountsQuery.isLoading}
selectedId={selectedAccount?.id ?? null}
onSelect={(account) => {
setSelectedAccount(account);
}}
/>
) : step === "select_repos" ? (
<SelectReposStep
repos={filteredRepos}
totalCount={(reposQuery.data ?? []).length}
loading={reposQuery.isLoading}
selectedIds={selectedRepoIds}
onToggle={handleToggleRepo}
query={repoQuery}
onQueryChange={setRepoQuery}
/>
) : step === "connecting" ? (
<ConnectingStep providerName={meta.name} />
) : (
<ConnectedStep
providerName={meta.name}
account={selectedAccount}
repoCount={selectedRepoIds.size}
/>
)}
</div>
{/* Error */}
{localError ? (
<div className="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-700">
{localError}
</div>
) : null}
{/* Footer actions */}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-end">
{step === "authorize" ? (
<>
<DenButton variant="secondary" onClick={onClose}>
Cancel
</DenButton>
<DenButton onClick={() => setStep("select_account")} icon={ArrowRight}>
Authorize with {meta.name}
</DenButton>
</>
) : step === "select_account" ? (
<>
<DenButton variant="secondary" onClick={() => setStep("authorize")}>
Back
</DenButton>
<DenButton
onClick={() => setStep("select_repos")}
disabled={!selectedAccount}
icon={ArrowRight}
>
Continue
</DenButton>
</>
) : step === "select_repos" ? (
<>
<DenButton variant="secondary" onClick={() => setStep("select_account")}>
Back
</DenButton>
<DenButton
onClick={() => void handleConnect()}
disabled={selectedRepoIds.size === 0}
loading={connectMutation.isPending}
>
{selectedRepoIds.size === 0
? "Select a repository"
: `Connect ${selectedRepoIds.size} ${selectedRepoIds.size === 1 ? "repo" : "repos"}`}
</DenButton>
</>
) : step === "connecting" ? (
<span className={buttonVariants({ variant: "secondary" })}>Working</span>
) : (
<DenButton onClick={onClose}>Done</DenButton>
)}
</div>
</div>
</div>
);
}
// ── Step components ────────────────────────────────────────────────────────
function ProviderBadge({ provider }: { provider: IntegrationProvider }) {
const bg = provider === "github" ? "bg-[#0f172a]" : "bg-[#2684FF]";
const label = provider === "github" ? "GH" : "BB";
return (
<div
className={`flex h-10 w-10 items-center justify-center rounded-[12px] text-[13px] font-semibold text-white ${bg}`}
aria-hidden="true"
>
{label}
</div>
);
}
function AuthorizeStep({ providerName, scopes }: { providerName: string; scopes: string[] }) {
return (
<div className="rounded-2xl border border-gray-100 bg-gray-50/60 p-5">
<p className="flex items-center gap-2 text-[13px] font-medium text-gray-900">
<ShieldCheck className="h-4 w-4 text-gray-500" />
{providerName} is requesting the following permissions
</p>
<ul className="mt-3 grid gap-2 text-[13px] text-gray-600">
{scopes.map((scope) => (
<li key={scope} className="flex items-center gap-2">
<Lock className="h-3.5 w-3.5 text-gray-400" />
<code className="rounded bg-white px-1.5 py-0.5 text-[12px] text-gray-700 ring-1 ring-gray-200">
{scope}
</code>
</li>
))}
</ul>
<p className="mt-4 text-[12px] leading-5 text-gray-400">
You will be redirected to {providerName} to approve access. This preview simulates that
redirect no data leaves your browser.
</p>
</div>
);
}
function SelectAccountStep({
accounts,
loading,
selectedId,
onSelect,
}: {
accounts: IntegrationAccount[];
loading: boolean;
selectedId: string | null;
onSelect: (account: IntegrationAccount) => void;
}) {
if (loading) {
return (
<div className="rounded-2xl border border-dashed border-gray-200 px-5 py-10 text-center text-[13px] text-gray-400">
Loading accounts
</div>
);
}
if (accounts.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-200 px-5 py-10 text-center text-[13px] text-gray-400">
No accounts available.
</div>
);
}
return (
<div className="overflow-hidden rounded-2xl border border-gray-100 bg-white">
<div className="divide-y divide-gray-100">
{accounts.map((account) => (
<DenSelectableRow
key={account.id}
title={account.name}
description={account.kind === "user" ? "Personal account" : "Organization"}
descriptionBelow
selected={selectedId === account.id}
onClick={() => onSelect(account)}
leading={
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#0f172a] text-[12px] font-semibold text-white">
{account.avatarInitial}
</div>
}
/>
))}
</div>
</div>
);
}
function SelectReposStep({
repos,
totalCount,
loading,
selectedIds,
onToggle,
query,
onQueryChange,
}: {
repos: IntegrationRepo[];
totalCount: number;
loading: boolean;
selectedIds: Set<string>;
onToggle: (repo: IntegrationRepo) => void;
query: string;
onQueryChange: (value: string) => void;
}) {
return (
<div className="grid gap-3">
<DenInput
type="search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="Filter repositories..."
/>
{loading ? (
<div className="rounded-2xl border border-dashed border-gray-200 px-5 py-10 text-center text-[13px] text-gray-400">
Loading repositories
</div>
) : repos.length === 0 ? (
<div className="rounded-2xl border border-dashed border-gray-200 px-5 py-10 text-center text-[13px] text-gray-400">
{totalCount === 0 ? "No repositories available on this account." : "No repositories match that filter."}
</div>
) : (
<div className="max-h-[320px] overflow-y-auto rounded-2xl border border-gray-100 bg-white">
<div className="divide-y divide-gray-100">
{repos.map((repo) => (
<DenSelectableRow
key={repo.id}
title={repo.fullName}
description={repo.description}
descriptionBelow
selected={selectedIds.has(repo.id)}
onClick={() => onToggle(repo)}
leading={<GitBranch className="h-4 w-4 text-gray-400" />}
aside={
repo.hasPlugins ? (
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
has plugins
</span>
) : null
}
/>
))}
</div>
</div>
)}
<p className="text-[12px] text-gray-400">
{selectedIds.size === 0
? "Select one or more repos to expose their plugins and skills."
: `${selectedIds.size} of ${totalCount} selected.`}
</p>
</div>
);
}
function ConnectingStep({ providerName }: { providerName: string }) {
return (
<div className="rounded-2xl border border-gray-100 bg-gray-50/60 px-5 py-10 text-center">
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center">
<svg aria-hidden="true" className="h-8 w-8 animate-spin text-gray-500" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<p className="text-[14px] font-medium text-gray-900">Installing {providerName} integration</p>
<p className="mt-1 text-[12px] text-gray-500">Registering webhooks and indexing repository manifests.</p>
</div>
);
}
function ConnectedStep({
providerName,
account,
repoCount,
}: {
providerName: string;
account: IntegrationAccount | null;
repoCount: number;
}) {
return (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50/60 px-5 py-6 text-center">
<CheckCircle2 className="mx-auto mb-2 h-8 w-8 text-emerald-700" />
<p className="text-[14px] font-medium text-gray-900">
{providerName} connected{account ? ` · ${account.name}` : ""}
</p>
<p className="mt-1 text-[12px] text-gray-500">
{repoCount} {repoCount === 1 ? "repository" : "repositories"} will now contribute plugins and skills.
</p>
</div>
);
}

View File

@@ -0,0 +1,277 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
/**
* Integrations model — the "connectors" layer that sits in front of Plugins.
*
* A plugin catalog is only populated once at least one integration is
* connected. Until then, plugins/skills/hooks/mcps all render empty.
*
* In this preview the OAuth flow is fully mocked: the UI walks through the
* same steps it would for a real integration (authorize → select account
* → select repositories → connecting → connected) but never leaves the app.
* State lives in the React Query cache, scoped to the dashboard subtree, so
* it is intentionally in-memory only.
*/
// ── Types ──────────────────────────────────────────────────────────────────
export type IntegrationProvider = "github" | "bitbucket";
export type IntegrationAccount = {
id: string;
name: string;
/** `user` or `org`/`workspace` */
kind: "user" | "org";
avatarInitial: string;
};
export type IntegrationRepo = {
id: string;
name: string;
fullName: string;
description: string;
/** whether this repo contributes plugins when connected */
hasPlugins: boolean;
};
export type ConnectedIntegration = {
id: string;
provider: IntegrationProvider;
account: IntegrationAccount;
repos: IntegrationRepo[];
connectedAt: string;
};
// ── Provider catalog (static UI metadata) ──────────────────────────────────
export type IntegrationProviderMeta = {
provider: IntegrationProvider;
name: string;
description: string;
docsHref: string;
scopes: string[];
};
export const INTEGRATION_PROVIDERS: Record<IntegrationProvider, IntegrationProviderMeta> = {
github: {
provider: "github",
name: "GitHub",
description: "Connect repositories on GitHub to discover plugins, skills, and MCP servers.",
docsHref: "https://docs.github.com/en/apps/oauth-apps",
scopes: ["repo", "read:org"],
},
bitbucket: {
provider: "bitbucket",
name: "Bitbucket",
description: "Connect Bitbucket workspaces to pull in plugins and skills from your team repos.",
docsHref: "https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/",
scopes: ["repository", "account"],
},
};
// ── Mock backing store (in-memory, keyed on the React Query cache) ────────
//
// The store below models what a real server would return. The React Query
// queryFn reads from this module-level array; mutations push/splice it and
// then invalidate the cache. Swapping for a real API later is a one-line
// change inside each queryFn/mutationFn.
let mockConnections: ConnectedIntegration[] = [];
export function getMockAccountsFor(provider: IntegrationProvider): IntegrationAccount[] {
if (provider === "github") {
return [
{ id: "acc_gh_user", name: "bshafii", kind: "user", avatarInitial: "B" },
{ id: "acc_gh_different_ai", name: "different-ai", kind: "org", avatarInitial: "D" },
{ id: "acc_gh_openwork", name: "openwork-labs", kind: "org", avatarInitial: "O" },
];
}
return [
{ id: "acc_bb_user", name: "bshafii", kind: "user", avatarInitial: "B" },
{ id: "acc_bb_openwork", name: "openwork", kind: "org", avatarInitial: "O" },
];
}
export function getMockReposFor(
provider: IntegrationProvider,
accountId: string,
): IntegrationRepo[] {
const tag = `${provider}:${accountId}`;
const base: IntegrationRepo[] = [
{
id: `${tag}:openwork`,
name: "openwork",
fullName: `${accountToLabel(accountId)}/openwork`,
description: "Core OpenWork monorepo — desktop, server, and orchestrator.",
hasPlugins: true,
},
{
id: `${tag}:openwork-plugins`,
name: "openwork-plugins",
fullName: `${accountToLabel(accountId)}/openwork-plugins`,
description: "Internal plugin marketplace: release kit, commit commands, linear groomer.",
hasPlugins: true,
},
{
id: `${tag}:den-infra`,
name: "den-infra",
fullName: `${accountToLabel(accountId)}/den-infra`,
description: "Infra-as-code for Den Cloud. No plugins yet.",
hasPlugins: false,
},
{
id: `${tag}:llm-ops`,
name: "llm-ops",
fullName: `${accountToLabel(accountId)}/llm-ops`,
description: "Evaluation harnesses, eval data, and dashboard for prompt regressions.",
hasPlugins: true,
},
{
id: `${tag}:design-system`,
name: "design-system",
fullName: `${accountToLabel(accountId)}/design-system`,
description: "Shared UI primitives used by the web and desktop apps.",
hasPlugins: false,
},
];
return base;
}
function accountToLabel(accountId: string): string {
if (accountId.includes("openwork-labs")) return "openwork-labs";
if (accountId.includes("openwork")) return "openwork";
if (accountId.includes("different-ai")) return "different-ai";
return "bshafii";
}
// ── Display helpers ────────────────────────────────────────────────────────
export function formatIntegrationTimestamp(value: string | null): string {
if (!value) return "Recently connected";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Recently connected";
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
}
export function getProviderMeta(provider: IntegrationProvider): IntegrationProviderMeta {
return INTEGRATION_PROVIDERS[provider];
}
// ── Query keys ─────────────────────────────────────────────────────────────
export const integrationQueryKeys = {
all: ["integrations"] as const,
list: () => [...integrationQueryKeys.all, "list"] as const,
accounts: (provider: IntegrationProvider) => [...integrationQueryKeys.all, "accounts", provider] as const,
repos: (provider: IntegrationProvider, accountId: string | null) =>
[...integrationQueryKeys.all, "repos", provider, accountId ?? "none"] as const,
};
// ── Hooks ──────────────────────────────────────────────────────────────────
async function simulateLatency(ms = 450) {
return new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function fetchConnections(): Promise<ConnectedIntegration[]> {
await simulateLatency(150);
return [...mockConnections];
}
export function useIntegrations() {
return useQuery({
queryKey: integrationQueryKeys.list(),
queryFn: fetchConnections,
});
}
export function useHasAnyIntegration(): { hasAny: boolean; isLoading: boolean } {
const { data, isLoading } = useIntegrations();
return { hasAny: (data?.length ?? 0) > 0, isLoading };
}
export function useIntegrationAccounts(provider: IntegrationProvider, enabled: boolean) {
return useQuery({
queryKey: integrationQueryKeys.accounts(provider),
queryFn: async () => {
await simulateLatency();
return getMockAccountsFor(provider);
},
enabled,
});
}
export function useIntegrationRepos(provider: IntegrationProvider, accountId: string | null) {
return useQuery({
queryKey: integrationQueryKeys.repos(provider, accountId),
queryFn: async () => {
if (!accountId) return [];
await simulateLatency();
return getMockReposFor(provider, accountId);
},
enabled: Boolean(accountId),
});
}
// ── Mutations ──────────────────────────────────────────────────────────────
export type ConnectInput = {
provider: IntegrationProvider;
account: IntegrationAccount;
repos: IntegrationRepo[];
};
export function useConnectIntegration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (input: ConnectInput): Promise<ConnectedIntegration> => {
// Simulate the remote OAuth exchange + repo webhook install roundtrip.
await simulateLatency(900);
const connection: ConnectedIntegration = {
id: `conn_${input.provider}_${input.account.id}_${Date.now()}`,
provider: input.provider,
account: input.account,
repos: input.repos,
connectedAt: new Date().toISOString(),
};
// Replace any prior connection on the same account (idempotent).
mockConnections = [
...mockConnections.filter(
(entry) => !(entry.provider === input.provider && entry.account.id === input.account.id),
),
connection,
];
return connection;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.list() });
queryClient.invalidateQueries({ queryKey: ["plugins"] });
},
});
}
export function useDisconnectIntegration() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (connectionId: string) => {
await simulateLatency(300);
mockConnections = mockConnections.filter((entry) => entry.id !== connectionId);
return connectionId;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: integrationQueryKeys.list() });
queryClient.invalidateQueries({ queryKey: ["plugins"] });
},
});
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useState } from "react";
import { Cable, Check, GitBranch, Unplug } from "lucide-react";
import { DenButton } from "../../../../_components/ui/button";
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
import { IntegrationConnectDialog } from "./integration-connect-dialog";
import {
type ConnectedIntegration,
type IntegrationProvider,
INTEGRATION_PROVIDERS,
formatIntegrationTimestamp,
useDisconnectIntegration,
useIntegrations,
} from "./integration-data";
export function IntegrationsScreen() {
const { data: connections = [], isLoading, error } = useIntegrations();
const disconnect = useDisconnectIntegration();
const [dialogProvider, setDialogProvider] = useState<IntegrationProvider | null>(null);
const connectedByProvider = connections.reduce<
Partial<Record<IntegrationProvider, ConnectedIntegration[]>>
>((acc, connection) => {
const list = acc[connection.provider] ?? [];
list.push(connection);
acc[connection.provider] = list;
return acc;
}, {});
const providers = Object.values(INTEGRATION_PROVIDERS);
return (
<DashboardPageTemplate
icon={Cable}
badgeLabel="Preview"
title="Integrations"
description="Connect to GitHub or Bitbucket. Once an account is linked, plugins and skills from those repositories show up on the Plugins page."
colors={["#E0F2FE", "#0C4A6E", "#0284C7", "#7DD3FC"]}
>
{error ? (
<div className="mb-6 rounded-[24px] border border-red-200 bg-red-50 px-5 py-4 text-[14px] text-red-700">
{error instanceof Error ? error.message : "Failed to load integrations."}
</div>
) : null}
{isLoading ? (
<div className="rounded-[28px] border border-gray-200 bg-white px-6 py-10 text-[15px] text-gray-500">
Loading integrations
</div>
) : (
<div className="grid gap-4 lg:grid-cols-2">
{providers.map((meta) => {
const providerConnections = connectedByProvider[meta.provider] ?? [];
const isConnected = providerConnections.length > 0;
return (
<div
key={meta.provider}
className="overflow-hidden rounded-2xl border border-gray-100 bg-white"
>
{/* Header */}
<div className="flex items-start gap-4 border-b border-gray-100 px-6 py-5">
<ProviderLogo provider={meta.provider} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-[15px] font-semibold text-gray-900">{meta.name}</h2>
{isConnected ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
<Check className="h-3 w-3" />
Connected
</span>
) : (
<span className="inline-flex rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-500">
Not connected
</span>
)}
</div>
<p className="mt-1 text-[13px] leading-[1.55] text-gray-500">{meta.description}</p>
</div>
<div className="shrink-0">
<DenButton
variant={isConnected ? "secondary" : "primary"}
size="sm"
onClick={() => setDialogProvider(meta.provider)}
>
{isConnected ? "Connect another" : "Connect"}
</DenButton>
</div>
</div>
{/* Body: connected accounts + repos */}
{isConnected ? (
<div className="grid gap-3 px-6 py-5">
{providerConnections.map((connection) => (
<ConnectionRow
key={connection.id}
connection={connection}
onDisconnect={() => disconnect.mutate(connection.id)}
busy={disconnect.isPending && disconnect.variables === connection.id}
/>
))}
</div>
) : (
<div className="px-6 py-5 text-[13px] text-gray-400">
Requires scopes: {meta.scopes.map((scope) => (
<code
key={scope}
className="mr-1 rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-600"
>
{scope}
</code>
))}
</div>
)}
</div>
);
})}
</div>
)}
<IntegrationConnectDialog
open={dialogProvider !== null}
provider={dialogProvider}
onClose={() => setDialogProvider(null)}
/>
</DashboardPageTemplate>
);
}
function ConnectionRow({
connection,
onDisconnect,
busy,
}: {
connection: ConnectedIntegration;
onDisconnect: () => void;
busy: boolean;
}) {
return (
<div className="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#0f172a] text-[11px] font-semibold text-white">
{connection.account.avatarInitial}
</div>
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-gray-900">{connection.account.name}</p>
<p className="truncate text-[12px] text-gray-400">
{connection.account.kind === "user" ? "Personal" : "Organization"} · Connected{" "}
{formatIntegrationTimestamp(connection.connectedAt)}
</p>
</div>
</div>
<DenButton
variant="destructive"
size="sm"
icon={Unplug}
loading={busy}
onClick={onDisconnect}
>
Disconnect
</DenButton>
</div>
{connection.repos.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{connection.repos.map((repo) => (
<span
key={repo.id}
className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-medium text-gray-600"
>
<GitBranch className="h-3 w-3 text-gray-400" />
{repo.fullName}
</span>
))}
</div>
) : null}
</div>
);
}
function ProviderLogo({ provider }: { provider: IntegrationProvider }) {
if (provider === "github") {
return (
<div className="flex h-10 w-10 items-center justify-center rounded-[12px] bg-[#0f172a] text-[13px] font-semibold text-white">
GH
</div>
);
}
return (
<div className="flex h-10 w-10 items-center justify-center rounded-[12px] bg-[#2684FF] text-[13px] font-semibold text-white">
BB
</div>
);
}

View File

@@ -24,6 +24,7 @@ import {
getBillingRoute,
getCustomLlmProvidersRoute,
getOrgAccessFlags,
getIntegrationsRoute,
getMembersRoute,
getOrgDashboardRoute,
getPluginsRoute,
@@ -114,6 +115,9 @@ function getDashboardPageTitle(pathname: string, orgSlug: string | null) {
if (pathname.startsWith(getPluginsRoute(orgSlug))) {
return "Plugins";
}
if (pathname.startsWith(getIntegrationsRoute(orgSlug))) {
return "Integrations";
}
if (pathname.startsWith(getBillingRoute(orgSlug)) || pathname === "/checkout") {
return "Billing";
}

View File

@@ -1,6 +1,10 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
import {
type ConnectedIntegration,
integrationQueryKeys,
} from "./integration-data";
/**
* Plugin primitives — mirror OpenCode / Claude Code's plugin surface:
@@ -15,6 +19,12 @@ import { useQuery } from "@tanstack/react-query";
* This file models the frontend shape only. Mock data is served through
* React Query so we can swap the queryFn for a real API call later without
* touching any consumers.
*
* Gating: the catalog is empty until the user has connected at least one
* integration (GitHub or Bitbucket) on the Integrations page. The queryFn
* reads the integrations cache and derives which plugins are visible from
* the set of connected repositories. Integration mutations invalidate
* `["plugins"]`, so connections and disconnections propagate automatically.
*/
// ── Primitive types ────────────────────────────────────────────────────────
@@ -91,6 +101,13 @@ export type DenPlugin = {
agents: PluginAgent[];
commands: PluginCommand[];
updatedAt: string;
/**
* Opt-in gating: which connected integration provider exposes this plugin.
* - "any" → visible once ANY integration is connected (e.g. marketplace output styles)
* - "github" → only visible after a GitHub account is connected
* - "bitbucket"→ only visible after a Bitbucket account is connected
*/
requiresProvider: "any" | "github" | "bitbucket";
};
// ── Display helpers ────────────────────────────────────────────────────────
@@ -196,6 +213,7 @@ const MOCK_PLUGINS: DenPlugin[] = [
{ id: "cmd_gh_pr", name: "/gh:pr", description: "Create a pull request from the current branch." },
],
updatedAt: "2026-04-10T12:00:00Z",
requiresProvider: "github",
},
{
id: "plg_commit_commands",
@@ -221,6 +239,7 @@ const MOCK_PLUGINS: DenPlugin[] = [
{ id: "cmd_cc_pr", name: "/pr", description: "Open a pull request." },
],
updatedAt: "2026-04-07T09:00:00Z",
requiresProvider: "any",
},
{
id: "plg_typescript_lsp",
@@ -254,6 +273,7 @@ const MOCK_PLUGINS: DenPlugin[] = [
agents: [],
commands: [],
updatedAt: "2026-03-28T16:45:00Z",
requiresProvider: "any",
},
{
id: "plg_linear",
@@ -286,6 +306,7 @@ const MOCK_PLUGINS: DenPlugin[] = [
{ id: "cmd_lin_new", name: "/linear:new", description: "File a new issue from the current context." },
],
updatedAt: "2026-04-02T18:12:00Z",
requiresProvider: "any",
},
{
id: "plg_openwork_release",
@@ -318,6 +339,7 @@ const MOCK_PLUGINS: DenPlugin[] = [
{ id: "cmd_ow_release", name: "/release", description: "Run the standardized release workflow." },
],
updatedAt: "2026-04-14T08:30:00Z",
requiresProvider: "github",
},
{
id: "plg_sentry",
@@ -346,6 +368,7 @@ const MOCK_PLUGINS: DenPlugin[] = [
agents: [],
commands: [],
updatedAt: "2026-03-20T11:00:00Z",
requiresProvider: "any",
},
{
id: "plg_explanatory_style",
@@ -371,18 +394,26 @@ const MOCK_PLUGINS: DenPlugin[] = [
agents: [],
commands: [],
updatedAt: "2026-03-12T14:22:00Z",
requiresProvider: "any",
},
];
async function fetchMockPlugins(): Promise<DenPlugin[]> {
// Simulate network latency so loading states render during development.
await new Promise((resolve) => setTimeout(resolve, 180));
return MOCK_PLUGINS;
function readConnectedProviders(client: QueryClient): Set<"github" | "bitbucket"> {
const connections = client.getQueryData<ConnectedIntegration[]>(integrationQueryKeys.list()) ?? [];
return new Set(connections.map((connection) => connection.provider));
}
async function fetchMockPlugin(id: string): Promise<DenPlugin | null> {
await new Promise((resolve) => setTimeout(resolve, 120));
return MOCK_PLUGINS.find((plugin) => plugin.id === id) ?? null;
function filterByConnectedProviders(
plugins: DenPlugin[],
connectedProviders: Set<"github" | "bitbucket">,
): DenPlugin[] {
if (connectedProviders.size === 0) {
return [];
}
return plugins.filter((plugin) => {
if (plugin.requiresProvider === "any") return true;
return connectedProviders.has(plugin.requiresProvider);
});
}
// ── Query hooks ────────────────────────────────────────────────────────────
@@ -397,16 +428,27 @@ export const pluginQueryKeys = {
};
export function usePlugins() {
const queryClient = useQueryClient();
return useQuery({
queryKey: pluginQueryKeys.list(),
queryFn: fetchMockPlugins,
queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 180));
const connectedProviders = readConnectedProviders(queryClient);
return filterByConnectedProviders(MOCK_PLUGINS, connectedProviders);
},
});
}
export function usePlugin(id: string) {
const queryClient = useQueryClient();
return useQuery({
queryKey: pluginQueryKeys.detail(id),
queryFn: () => fetchMockPlugin(id),
queryFn: async () => {
await new Promise((resolve) => setTimeout(resolve, 120));
const connectedProviders = readConnectedProviders(queryClient);
const visible = filterByConnectedProviders(MOCK_PLUGINS, connectedProviders);
return visible.find((plugin) => plugin.id === id) ?? null;
},
enabled: Boolean(id),
});
}

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import { useMemo, useState } from "react";
import {
Cable,
FileText,
Puzzle,
Search,
@@ -13,8 +14,10 @@ import { PaperMeshGradient } from "@openwork/ui/react";
import { UnderlineTabs } from "../../../../_components/ui/tabs";
import { DashboardPageTemplate } from "../../../../_components/ui/dashboard-page-template";
import { DenInput } from "../../../../_components/ui/input";
import { getPluginRoute } from "../../../../_lib/den-org";
import { buttonVariants } from "../../../../_components/ui/button";
import { getIntegrationsRoute, getPluginRoute } from "../../../../_lib/den-org";
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
import { useHasAnyIntegration } from "./integration-data";
import {
getPluginCategoryLabel,
getPluginPartsSummary,
@@ -33,6 +36,7 @@ const PLUGIN_TABS = [
export function PluginsScreen() {
const { orgSlug } = useOrgDashboard();
const { data: plugins = [], isLoading, error } = usePlugins();
const { hasAny: hasAnyIntegration, isLoading: integrationsLoading } = useHasAnyIntegration();
const [activeView, setActiveView] = useState<PluginView>("plugins");
const [query, setQuery] = useState("");
@@ -145,17 +149,19 @@ export function PluginsScreen() {
</div>
) : null}
{isLoading ? (
{isLoading || integrationsLoading ? (
<div className="rounded-[28px] border border-gray-200 bg-white px-6 py-10 text-[15px] text-gray-500">
Loading plugin catalog...
</div>
) : !hasAnyIntegration ? (
<ConnectIntegrationEmptyState integrationsHref={getIntegrationsRoute(orgSlug)} />
) : activeView === "plugins" ? (
filteredPlugins.length === 0 ? (
<EmptyState
title={plugins.length === 0 ? "No plugins available yet." : "No plugins match that search."}
description={
plugins.length === 0
? "Once you connect a marketplace, discovered plugins will appear here."
? "None of your connected integrations expose plugins yet. Connect another repository to discover more."
: "Try a different search term or browse the skills, hooks, or MCPs tabs."
}
/>
@@ -257,6 +263,32 @@ function EmptyState({ title, description }: { title: string; description: string
);
}
function ConnectIntegrationEmptyState({ integrationsHref }: { integrationsHref: string }) {
return (
<div className="rounded-[32px] border border-dashed border-gray-200 bg-white px-6 py-12 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-[14px] bg-gray-100 text-gray-500">
<Cable className="h-6 w-6" />
</div>
<p className="text-[16px] font-medium tracking-[-0.03em] text-gray-900">
Connect an integration to discover plugins
</p>
<p className="mx-auto mt-3 max-w-[520px] text-[15px] leading-8 text-gray-500">
Plugins, skills, hooks, and MCP servers are sourced from the repositories you connect on the
Integrations page. Connect GitHub or Bitbucket to see your catalog populate.
</p>
<div className="mt-6 flex justify-center">
<Link
href={integrationsHref}
className={buttonVariants({ variant: "primary" })}
>
<Cable className="h-4 w-4" aria-hidden="true" />
Open Integrations
</Link>
</div>
</div>
);
}
type PrimitiveRow = {
id: string;
title: string;

View File

@@ -0,0 +1,5 @@
import { IntegrationsScreen } from "../_components/integrations-screen";
export default function IntegrationsPage() {
return <IntegrationsScreen />;
}