mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { IntegrationsScreen } from "../_components/integrations-screen";
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
return <IntegrationsScreen />;
|
||||
}
|
||||
Reference in New Issue
Block a user