diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json
index b8ba2435b..e41fa055a 100644
--- a/module/services/frontend/code/package-lock.json
+++ b/module/services/frontend/code/package-lock.json
@@ -15369,7 +15369,7 @@
},
"packages/codefly-ui": {
"name": "@codefly-dev/ui",
- "version": "0.12.15",
+ "version": "0.12.16",
"license": "Apache-2.0",
"dependencies": {
"@base-ui/react": "^1.3.0",
@@ -15518,7 +15518,7 @@
},
"packages/saas-ui": {
"name": "@codefly-dev/saas-ui",
- "version": "0.12.15",
+ "version": "0.12.16",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "1.9.1",
diff --git a/module/services/frontend/code/packages/codefly-ui/package.json b/module/services/frontend/code/packages/codefly-ui/package.json
index af8fac509..aa55415be 100644
--- a/module/services/frontend/code/packages/codefly-ui/package.json
+++ b/module/services/frontend/code/packages/codefly-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/ui",
- "version": "0.12.15",
+ "version": "0.12.16",
"description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.",
"repository": {
"type": "git",
diff --git a/module/services/frontend/code/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md
index 428c2bcca..ecd5ec0ee 100644
--- a/module/services/frontend/code/packages/saas-ui/README.md
+++ b/module/services/frontend/code/packages/saas-ui/README.md
@@ -203,7 +203,7 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed
- `` or
`` — lists an org's connected sources
(repo · status · paths · branch · boundary · live updates · last sync) with per-row **Sync**/**Delete** and
- a **Connect GitHub** action. The last-sync cell shows whichever clock applies:
+ the connect actions the host's catalog admits. The last-sync cell shows whichever clock applies:
`last_synced_at` for a provider that pulls, or `last ingest ·
` for a github source, whose change sets the compiler enqueues
(a webhook delivery, or a reconcile pass — a declared schedule's occurrence or
@@ -268,6 +268,16 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed
it keeps syncing, but the host connects no new source of that provider. The
flag is keyed on `conformance_gap`, so an older host that sends no
conformance fields flags nothing.
+ What the panel offers to connect is the host's connector catalog
+ (`GetDatasourceCatalog`, read through the client's optional `getCatalog`): a
+ provider whose `accepts_new_sources` is true gets its connect action (today
+ **Connect GitHub**; object storage's form joins once the host admits it) — and every provider that takes no new source is
+ named under the header with the host's gap sentence (and, for a provider that
+ still accepts operations, that those can be connected). An empty catalog offers
+ nothing, because that is the host admitting nothing. A client without
+ `getCatalog`, or whose catalog read failed, offers **Connect GitHub** alone, as
+ before the catalog was read; the host refuses a provider it does not admit
+ either way.
- `` — the connect form (repo, paths, branch,
target collection, webhook secret, and an access token only on the PAT path).
Its Authentication choice always offers **Public repository (no token)**: the
@@ -386,8 +396,8 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed
phase names and getting the terminal cases wrong. `` is the card, and `useSourceSync(client, orgId, source)` the read.
- Hooks over a `DatasourceClient`: `useListSources`, `useAddGitHubSource`,
- `useUpdateSource`, `useSyncSource`, `useDeleteSource`, `useAccessibleScopes`,
- `useMyAccountLinks`.
+ `useDatasourceCatalog`, `useUpdateSource`,
+ `useSyncSource`, `useDeleteSource`, `useAccessibleScopes`, `useMyAccountLinks`.
- ``, behind the row's **Edit**, changes
what a connected source fetches: its branch and its path and file-type
filters, bound by the same schema the connect form uses. It offers the connect
diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json
index 89b66f66f..02bb811d6 100644
--- a/module/services/frontend/code/packages/saas-ui/package.json
+++ b/module/services/frontend/code/packages/saas-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/saas-ui",
- "version": "0.12.15",
+ "version": "0.12.16",
"description": "Reusable SaaS-domain frontend components (datasources, …) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.",
"repository": {
"type": "git",
diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/datasources-panel.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/datasources-panel.test.tsx
index 9d7190749..590b0ba07 100644
--- a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/datasources-panel.test.tsx
+++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/datasources-panel.test.tsx
@@ -1745,3 +1745,61 @@ describe("DatasourcesPanel for a viewer who manages nothing", () => {
expect(link.getAttribute("href")).toBe("/admin/datasources");
});
});
+
+describe("DatasourcesPanel connector catalog", () => {
+ const github = {
+ connector: "github",
+ displayName: "GitHub",
+ description: "A GitHub repository.",
+ acceptsNewSources: true,
+ gap: "",
+ acceptsOperations: false,
+ };
+ const objectStorage = {
+ connector: "upload",
+ displayName: "Object storage",
+ description: "A bucket under a key prefix.",
+ acceptsNewSources: false,
+ gap: "no cursor, so every sync re-sends every object",
+ acceptsOperations: false,
+ };
+ const api = {
+ connector: "api",
+ displayName: "HTTP API",
+ description: "An HTTP API.",
+ acceptsNewSources: false,
+ gap: "one untyped response",
+ acceptsOperations: true,
+ };
+
+ it("offers what the catalog accepts and names the rest with the host's reason", async () => {
+ const client = fakeClient({
+ getCatalog: vi.fn(async () => [github, objectStorage, api]),
+ });
+ renderWithClient();
+
+ const refused = await screen.findByRole("list", {
+ name: "Providers that take no new sources",
+ });
+ expect(
+ [...refused.querySelectorAll("li")].map((item) => item.textContent),
+ ).toEqual([
+ "Object storage takes no new sources to sync yet: no cursor, so every sync re-sends every object.",
+ // An API takes no source to sync, and is still connected for calls.
+ "HTTP API takes no new sources to sync yet: one untyped response. Its operations can still be connected.",
+ ]);
+ expect(screen.getByRole("button", { name: "Connect GitHub" })).toBeTruthy();
+ });
+
+ it("withholds GitHub when the catalog does not admit it", async () => {
+ const client = fakeClient({
+ getCatalog: vi.fn(async () => [
+ { ...github, acceptsNewSources: false, gap: "not configured here" },
+ ]),
+ });
+ renderWithClient();
+
+ await screen.findByText(/GitHub takes no new sources to sync yet/);
+ expect(screen.queryByRole("button", { name: "Connect GitHub" })).toBeNull();
+ });
+});
diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx
index c9114c216..f1abe1e73 100644
--- a/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx
+++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/gateway.test.tsx
@@ -97,6 +97,17 @@ function stubFetch(responseBody: unknown): { calls: FetchCall[] } {
return stubFetchSequence([reply(responseBody)]);
}
+const githubCatalog = {
+ providers: [
+ {
+ connector: "github",
+ displayName: "GitHub",
+ description: "A GitHub repository.",
+ acceptsNewSources: true,
+ },
+ ],
+};
+
const oneSource = {
datasources: [
{
@@ -177,6 +188,7 @@ describe("createDatasourceClient", () => {
expected: { branch: "main", paths: [], fileExtensions: [] },
}),
deleteSource: () => client.deleteSource("org-1", "ds-1"),
+ getCatalog: () => client.getCatalog!(),
// The stub answers {} to everything; an operation whose answer must
// carry a record rejects on it, after the call this test counts.
beginAccountLink: () =>
@@ -558,7 +570,9 @@ describe("DatasourcesPanel gateway binding", () => {
])(
"offers %s the management controls only when their credential names the tier",
async (_who, claims, manages) => {
- stubFetch(oneSource);
+ // One body answers every call, so it carries the catalog too: GitHub
+ // is offered because the host's catalog admits it.
+ stubFetch({ ...oneSource, ...githubCatalog });
render(
+ !!catalog.data?.some(
+ (provider) =>
+ provider.connector === connector && provider.acceptsNewSources,
+ );
+ const offerGitHub = catalog.isSuccess ? accepts("github") : true;
+ const refusedProviders = catalog.isSuccess
+ ? catalog.data.filter((provider) => !provider.acceptsNewSources)
+ : [];
const reconcileMutation = useReconcileSource(client);
const deleteMutation = useDeleteSource(client);
const updateMutation = useUpdateSource(client);
@@ -599,12 +615,27 @@ function DatasourcesPanelView({
Repositories this organization ingests from.
+ {provider.displayName} takes no new sources to sync yet
+ {provider.gap ? `: ${provider.gap}.` : "."}
+ {provider.acceptsOperations &&
+ " Its operations can still be connected."}
+
+ ))}
+
+ )}
{scopes.isError ? (
diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/gateway.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/gateway.ts
index b7d8b8a65..180b9e668 100644
--- a/module/services/frontend/code/packages/saas-ui/src/datasources/gateway.ts
+++ b/module/services/frontend/code/packages/saas-ui/src/datasources/gateway.ts
@@ -265,6 +265,17 @@ export function datasourceClientOverTransport(
// The host starts a GitHub source's first sync as it connects it.
notifySourceSyncRequested(response.datasource?.id ?? "");
},
+ async getCatalog() {
+ const response = await client.getDatasourceCatalog({});
+ return response.providers.map((provider) => ({
+ connector: provider.connector,
+ displayName: provider.displayName,
+ description: provider.description,
+ acceptsNewSources: provider.acceptsNewSources,
+ gap: provider.acceptsNewSources ? "" : provider.conformanceGap,
+ acceptsOperations: provider.acceptsOperations,
+ }));
+ },
async addPersonalApiSource(input) {
// No credential and no OAuth client: the authorization-code grant
// stores only its selector on the source, and the host resolves the
diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/queries.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/queries.ts
index b249c566a..676fecdf8 100644
--- a/module/services/frontend/code/packages/saas-ui/src/datasources/queries.ts
+++ b/module/services/frontend/code/packages/saas-ui/src/datasources/queries.ts
@@ -38,6 +38,21 @@ export function useListSources(client: DatasourceClient, orgId: string) {
});
}
+/**
+ * The host's connector catalog, which providers a surface may offer. Disabled
+ * for a client that does not read it; the catalog changes only with a release.
+ */
+export function useDatasourceCatalog(client: DatasourceClient) {
+ const getCatalog = client.getCatalog?.bind(client);
+ return useQuery({
+ queryKey: ["datasource-catalog"],
+ queryFn: () => getCatalog?.() ?? [],
+ enabled: !!getCatalog,
+ retry: false,
+ staleTime: Number.POSITIVE_INFINITY,
+ });
+}
+
export function useAddGitHubSource(client: DatasourceClient) {
const queryClient = useQueryClient();
return useMutation({
diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/types.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/types.ts
index 08640df4f..0b31217bf 100644
--- a/module/services/frontend/code/packages/saas-ui/src/datasources/types.ts
+++ b/module/services/frontend/code/packages/saas-ui/src/datasources/types.ts
@@ -202,6 +202,26 @@ export interface ConnectGitHubInput {
webhookSecret: string;
}
+/**
+ * One provider of the host's connector catalog (`GetDatasourceCatalog`). A
+ * surface offers a provider whose `acceptsNewSources` is true and flags the
+ * rest with their `gap`, which is the host's sentence for why it takes none.
+ */
+export interface DatasourceProviderView {
+ /** The connector's registry key, e.g. "github" or "upload". */
+ connector: string;
+ displayName: string;
+ description: string;
+ acceptsNewSources: boolean;
+ /** Empty when the provider accepts new sources. */
+ gap: string;
+ /**
+ * Whether a source of it may be connected for its operations even though it
+ * takes none to sync (the `api` provider).
+ */
+ acceptsOperations: boolean;
+}
+
/**
* The person's own `api` source, ready for `addPersonalApiSource`: an API base
* URL the person signs in to through the deployment's provider registration.
@@ -419,6 +439,11 @@ export interface DatasourceClient {
listActivity?(orgId: string, sourceId: string): Promise;
listSources(orgId: string): Promise;
addGitHubSource(input: ConnectGitHubInput): Promise;
+ /**
+ * The host's connector catalog. Optional, and without it a surface offers
+ * GitHub alone, as it did before the catalog was read.
+ */
+ getCatalog?(): Promise;
/**
* Creates the caller's own `api` source with the OAuth 2.0
* authorization-code grant (`API_CREDENTIAL_KIND_OAUTH2` +
diff --git a/module/services/frontend/code/packages/saas-ui/src/index.ts b/module/services/frontend/code/packages/saas-ui/src/index.ts
index 3f9b2d2f9..338d40d46 100644
--- a/module/services/frontend/code/packages/saas-ui/src/index.ts
+++ b/module/services/frontend/code/packages/saas-ui/src/index.ts
@@ -57,6 +57,7 @@ export {
export {
useAccessibleScopes,
useAddGitHubSource,
+ useDatasourceCatalog,
useDeleteSource,
useListSources,
useReconcileSource,
@@ -120,6 +121,7 @@ export type {
DatasourceClient,
DatasourceDirectoryView,
DatasourceProviderName,
+ DatasourceProviderView,
DatasourceStatusName,
DatasourceView,
DomainView,