diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index cb7822b6c..a25009f8c 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15627,7 +15627,7 @@ }, "packages/codefly-ui": { "name": "@codefly-dev/ui", - "version": "0.13.0", + "version": "0.13.1", "license": "Apache-2.0", "dependencies": { "@base-ui/react": "^1.3.0", @@ -15776,7 +15776,7 @@ }, "packages/saas-ui": { "name": "@codefly-dev/saas-ui", - "version": "0.13.0", + "version": "0.13.1", "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 16c35d906..95a7bec4b 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.13.0", + "version": "0.13.1", "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 529c15a23..583ae6510 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -203,7 +203,11 @@ 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: + catalog-driven connect actions. Every provider returned by `GetDatasourceCatalog` + is listed; a provider refusing new sources keeps its exact `conformance_gap` + text visible. A removed provider disappears, including an open form, on the + next catalog refresh (15 seconds or window focus). A failed catalog read + withdraws connect forms and offers a retry. 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 +272,34 @@ 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. + +The optional `DatasourceClient.getCatalog()` and `addSource(input)` methods are +implemented by both kit transport bindings. Existing injected clients compile +unchanged; without catalog support the panel states that the catalog is +unavailable and invents no provider. No new panel prop is required. + +The host descriptor owns field keys, labels, help, required flags and credential +modes. The adapter uses the SDK's `AddSource` message descriptor to encode the +matching config oneof: text stays text, repeated strings accept one value per +line, and other field types accept JSON. No provider table lives in the panel. +A provider with unknown config fields stays visible with a newer-kit requirement; +it is never silently dropped. A host adding a wire-contract variant still +requires a compatible SDK. Prefixes and include/exclude patterns appear only +when the host describes them. This UI does not change provider admission. + +`ConnectSourceInput.credential` is a typed, transient value. Password inputs are +uncontrolled and cleared before the call; neither those values nor operation +errors enter a query or mutation cache, storage, logs, or component state. +Failures use neutral wording because an error may echo submitted secrets. The +existing app-install return form also clears its credentials and calls directly. +The catalog also gates installation-return redemption: a missing, refused, +unsupported or failed catalog entry offers no return form and triggers no +completion call. Organization-app support follows the advertised credential +mode and the SDK setup operations matching its connector key; a credential mode +alone never offers another connector’s installation workflow. No-credential and organization-app modes send no secret. Connecting still +refreshes sources, collection grants and readable boundaries. Preview stories +`CatalogProviders` and `RefusedProvider` exercise the descriptor-driven surface. + - `` — 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 diff --git a/module/services/frontend/code/packages/saas-ui/package.json b/module/services/frontend/code/packages/saas-ui/package.json index 042a71cf1..58a1c9daa 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.13.0", + "version": "0.13.1", "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__/catalog.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/catalog.test.tsx new file mode 100644 index 000000000..5862b5b8a --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/catalog.test.tsx @@ -0,0 +1,428 @@ +import { DatasourceService } from "@codefly-dev/saas-sdk"; +import { createRouterTransport } from "@connectrpc/connect"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { + CatalogProviders, + RefusedProvider, +} from "../../../stories/datasources.stories.js"; +import { + exampleProvider, + refusedProvider, + repositoryProvider, +} from "../../../stories/catalog-fixtures.js"; +import { DatasourcesPanel } from "../datasources-panel.js"; +import { datasourceClientOverTransport } from "../gateway.js"; +import type { + ConnectSourceInput, + DatasourceClient, + DatasourceProviderView, +} from "../types.js"; + +afterEach(cleanup); +function clientWith(providers: DatasourceProviderView[]): DatasourceClient { + return { + listSources: async () => [], + addGitHubSource: vi.fn(), + reconcileSource: vi.fn(), + lookupReconcileSource: vi.fn(), + deleteSource: vi.fn(), + getCatalog: vi.fn(async () => providers), + addSource: vi.fn(async () => {}), + }; +} +function mount(client: DatasourceClient) { + const queries = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const view = render( + + + , + ); + return { ...view, queries }; +} +async function fill(provider: DatasourceProviderView) { + fireEvent.click( + await screen.findByRole("button", { + name: `Connect ${provider.displayName}`, + }), + ); + fireEvent.change(screen.getByLabelText("Target collection"), { + target: { value: "Example collection" }, + }); + fireEvent.change(screen.getByLabelText(provider.fields[0].displayName), { + target: { value: "example/" }, + }); + fireEvent.change(screen.getByLabelText("Credential"), { + target: { value: "example-secret" }, + }); +} + +it("offers every admitted descriptor, including one unknown to this kit, and passes its own fields", async () => { + const client = clientWith([ + repositoryProvider, + exampleProvider, + refusedProvider, + ]); + mount(client); + for (const provider of [repositoryProvider, exampleProvider]) { + await fill(provider); + if (provider === exampleProvider) { + fireEvent.change(screen.getByLabelText("Include patterns"), { + target: { value: "docs/**\nnotes/**" }, + }); + fireEvent.change(screen.getByLabelText("Exclude patterns"), { + target: { value: "**/private/**" }, + }); + expect(screen.queryByLabelText("Repository")).toBeNull(); + } + fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + await screen.findByRole("button", { + name: `Connect ${provider.displayName}`, + }); + } + expect(client.addSource).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + provider: exampleProvider, + config: { + prefix: "example/", + include_patterns: "docs/**\nnotes/**", + exclude_patterns: "**/private/**", + }, + credential: { + kind: "static_secret", + secret: "example-secret", + webhookSecret: "", + }, + }), + ); + expect(client.addGitHubSource).not.toHaveBeenCalled(); + expect(screen.getByText(refusedProvider.gap).textContent).toBe( + refusedProvider.gap, + ); + expect( + screen.queryByRole("button", { + name: `Connect ${refusedProvider.displayName}`, + }), + ).toBeNull(); +}); + +it.each(["removed", "refused", "failed"])( + "withdraws an open form when a catalog refresh is %s", + async (change) => { + const client = clientWith([exampleProvider]); + const { queries } = mount(client); + await fill(exampleProvider); + vi.mocked(client.getCatalog!).mockImplementation(async () => { + if (change === "failed") throw new Error("example outage"); + return change === "removed" + ? [] + : [ + { + ...exampleProvider, + acceptsNewSources: false, + gap: refusedProvider.gap, + }, + ]; + }); + await act(() => + queries.invalidateQueries({ queryKey: ["datasource-catalog"] }), + ); + await waitFor(() => + expect(screen.queryByLabelText("Credential")).toBeNull(), + ); + expect( + screen.queryByRole("button", { + name: `Connect ${exampleProvider.displayName}`, + }), + ).toBeNull(); + expect(client.addSource).not.toHaveBeenCalled(); + if (change === "refused") + expect(screen.getByText(refusedProvider.gap)).toBeTruthy(); + if (change === "failed") + expect(screen.getByRole("alert").textContent).toContain( + "Could not load the provider catalog", + ); + }, +); + +it.each(["removed", "refused"])( + "does not redeem or offer an installation return when its catalog provider is %s", + async (change) => { + window.history.replaceState( + null, + "", + "/?installation_id=42&state=example&code=example", + ); + const provider = { + ...repositoryProvider, + acceptsNewSources: false, + gap: refusedProvider.gap, + }; + const client = clientWith(change === "removed" ? [] : [provider]); + client.completeGitHubAppSetup = vi.fn(async () => ({ + installationId: "42", + repositories: [], + })); + try { + mount(client); + await screen.findByText( + change === "removed" + ? "The host reports no source providers." + : refusedProvider.gap, + ); + expect(client.completeGitHubAppSetup).not.toHaveBeenCalled(); + expect(screen.queryByLabelText("Repository")).toBeNull(); + expect(screen.queryByRole("button", { name: /^Connect/ })).toBeNull(); + expect(window.location.search).toBe(""); + } finally { + window.history.replaceState(null, "", "/"); + } + }, +); + +it("withdraws the installation-return form when its provider leaves the catalog", async () => { + window.history.replaceState( + null, + "", + "/?installation_id=42&state=example&code=example", + ); + const client = clientWith([repositoryProvider]); + client.completeGitHubAppSetup = vi.fn(async () => ({ + installationId: "42", + repositories: [ + { repo: "acme/example", defaultBranch: "main", alreadyConnected: false }, + ], + })); + try { + const { queries } = mount(client); + await screen.findByLabelText("Repository"); + vi.mocked(client.getCatalog!).mockResolvedValue([]); + await act(() => + queries.invalidateQueries({ queryKey: ["datasource-catalog"] }), + ); + await screen.findByText("The host reports no source providers."); + expect(screen.queryByLabelText("Repository")).toBeNull(); + expect(screen.queryByRole("button", { name: /^Connect/ })).toBeNull(); + } finally { + window.history.replaceState(null, "", "/"); + } +}); + +it.each([false, true])( + "clears secrets before a pending call and never caches or echoes them (reject=%s)", + async (reject) => { + const client = clientWith([exampleProvider]); + let finish!: () => void; + client.addSource = vi.fn( + () => + new Promise((resolve, fail) => { + finish = () => + reject ? fail(new Error("example-secret")) : resolve(); + }), + ); + const { queries } = mount(client); + await fill(exampleProvider); + const input = screen.getByLabelText("Credential") as HTMLInputElement; + const form = screen.getByRole("form", { + name: `Connect ${exampleProvider.displayName}`, + }); + fireEvent.submit(form); + fireEvent.submit(form); + expect(client.addSource).toHaveBeenCalledTimes(1); + expect(input.value).toBe(""); + expect(queries.getMutationCache().getAll()).toHaveLength(0); + await act(async () => finish()); + expect( + JSON.stringify( + queries + .getQueryCache() + .getAll() + .map((query) => query.state), + ), + ).not.toContain("example-secret"); + expect(document.body.textContent).not.toContain("example-secret"); + if (reject) + expect( + (screen.getByLabelText("Credential") as HTMLInputElement).value, + ).toBe(""); + }, +); + +it("discards a credential on cancellation and provider change", async () => { + mount(clientWith([exampleProvider, repositoryProvider])); + await fill(exampleProvider); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + fireEvent.click( + screen.getByRole("button", { + name: `Connect ${exampleProvider.displayName}`, + }), + ); + expect(screen.getByLabelText("Credential")).toHaveProperty("value", ""); + fireEvent.change(screen.getByLabelText("Credential"), { + target: { value: "example-secret" }, + }); + fireEvent.click( + screen.getByRole("button", { + name: `Connect ${repositoryProvider.displayName}`, + }), + ); + expect(screen.getByLabelText("Credential")).toHaveProperty("value", ""); +}); + +it("keeps today's client and props valid without inventing a provider when catalog support is absent", async () => { + const legacy = clientWith([]); + delete legacy.getCatalog; + delete legacy.addSource; + mount(legacy); + expect( + await screen.findByText("Provider catalog unavailable with this client."), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /^Connect/ })).toBeNull(); +}); + +it("reads the host catalog over the existing transport and encodes two different AddSource oneofs", async () => { + const received: unknown[] = []; + const transport = createRouterTransport((router) => + router.service(DatasourceService, { + getDatasourceCatalog: () => ({ + providers: [ + { + provider: 1, + connector: "github", + displayName: "Example repository", + acceptsNewSources: true, + credentialModes: [4], + configFields: [ + { key: "repo", displayName: "Repository", required: true }, + { key: "paths", displayName: "Paths" }, + ], + }, + { + provider: 4, + connector: "upload", + displayName: "Example bucket", + acceptsNewSources: true, + credentialModes: [4], + configFields: [ + { key: "bucket", displayName: "Bucket", required: true }, + { key: "prefix", displayName: "Prefix" }, + { key: "max_objects", displayName: "Limit" }, + ], + }, + { + provider: 3, + connector: "crawler", + displayName: "Example refused", + conformanceGap: refusedProvider.gap, + }, + ], + }), + addSource: (input) => { + received.push(input); + return {}; + }, + }), + ); + const client = datasourceClientOverTransport(transport); + const catalog = await client.getCatalog!(); + expect(catalog[2].gap).toBe(refusedProvider.gap); + const connect = ( + provider: DatasourceProviderView, + config: ConnectSourceInput["config"], + ) => + client.addSource!({ + orgId: "example-org", + provider, + config, + targetCollection: "Example", + credential: { kind: "static_secret", secret: "example-secret" }, + }); + await connect(catalog[0], { repo: "acme/example", paths: "docs/\nnotes/" }); + await connect(catalog[1], { + bucket: "example-bucket", + prefix: "docs/", + max_objects: "12", + }); + expect(received).toMatchObject([ + { + provider: 1, + config: { + case: "github", + value: { repo: "acme/example", paths: ["docs/", "notes/"] }, + }, + credential: "example-secret", + }, + { + provider: 4, + config: { + case: "upload", + value: { bucket: "example-bucket", prefix: "docs/", maxObjects: 12 }, + }, + credential: "example-secret", + }, + ]); + await expect(connect(catalog[2], {})).rejects.toThrow("does not accept"); + await expect( + connect(catalog[1], { bucket: "example", max_objects: "not-json" }), + ).rejects.toThrow("valid JSON"); + expect(received).toHaveLength(2); +}); + +it("requires both the catalog credential mode and connector setup operations", async () => { + const transport = createRouterTransport((router) => + router.service(DatasourceService, { + getDatasourceCatalog: () => ({ + providers: [ + { + provider: 1, + connector: repositoryProvider.connector, + displayName: "Example one", + credentialModes: [4], + }, + { + provider: 4, + connector: "example-two", + displayName: "Example two", + credentialModes: [2], + }, + ], + }), + }), + ); + const providers = + await datasourceClientOverTransport(transport).getCatalog!(); + expect(providers[0].supportsOrganizationAppSetup).toBe(false); + expect(providers[1].supportsOrganizationAppSetup).toBe(false); +}); + +it.each([CatalogProviders, RefusedProvider])( + "renders the admitting and refusing owner previews", + async (story) => { + const Story = story.render; + render(); + expect(await screen.findByText(refusedProvider.gap)).toBeTruthy(); + const region = screen.getByRole("region", { name: "Source providers" }); + if (story === CatalogProviders) + expect( + within(region).getByRole("button", { + name: `Connect ${exampleProvider.displayName}`, + }), + ).toBeTruthy(); + else + expect( + within(region).queryByRole("button", { name: /^Connect/ }), + ).toBeNull(); + }, +); 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..2cdc0ec0c 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 @@ -19,6 +19,8 @@ import type { SourceReconcileReceipt, } from "../types.js"; +import { repositoryProvider } from "../../../stories/catalog-fixtures.js"; + afterEach(cleanup); function renderWithClient(ui: ReactElement) { @@ -34,6 +36,8 @@ function fakeClient( overrides: Partial = {}, ): DatasourceClient { return { + getCatalog: vi.fn(async () => [repositoryProvider]), + addSource: vi.fn(async () => {}), listSources: vi.fn(async () => [] as DatasourceView[]), addGitHubSource: vi.fn(async () => {}), reconcileSource: vi.fn(async (_orgId, _id, effectId: string) => ({ @@ -81,7 +85,7 @@ async function openConnectForm(client: DatasourceClient) { fireEvent.change(screen.getByLabelText("Target collection"), { target: { value: "docs" }, }); - fireEvent.change(screen.getByLabelText("Access token"), { + fireEvent.change(screen.getByLabelText("Credential"), { target: { value: "ghp_token" }, }); } @@ -217,17 +221,20 @@ describe("DatasourcesPanel", () => { ["paused", "Paused", "warning"], ["degraded", "Degraded", "danger"], ["unknown", "Unknown", "neutral"], - ] as const)("paints a %s source in its status tone", async (status, label, tone) => { - const source: DatasourceView = { ...sampleSource, status }; - const client = fakeClient({ listSources: vi.fn(async () => [source]) }); - renderWithClient(); - - const badge = (await screen.findByText(label)).closest( - "[data-slot=badge]", - ); - expect(badge?.getAttribute("data-tone")).toBe(tone); - expect(badge?.querySelector("[data-slot=badge-dot]")).toBeTruthy(); - }); + ] as const)( + "paints a %s source in its status tone", + async (status, label, tone) => { + const source: DatasourceView = { ...sampleSource, status }; + const client = fakeClient({ listSources: vi.fn(async () => [source]) }); + renderWithClient(); + + const badge = (await screen.findByText(label)).closest( + "[data-slot=badge]", + ); + expect(badge?.getAttribute("data-tone")).toBe(tone); + expect(badge?.querySelector("[data-slot=badge-dot]")).toBeTruthy(); + }, + ); it("leaves the status cell quiet for an active source", async () => { // Nearly every row is active, so badging it too buries the states that @@ -380,7 +387,7 @@ describe("DatasourcesPanel", () => { expect(screen.getByText("Never")).toBeTruthy(); }); - it("submits the connect form through addGitHubSource", async () => { + it("submits the connect form through addSource", async () => { const client = fakeClient(); renderWithClient(); @@ -390,34 +397,36 @@ describe("DatasourcesPanel", () => { fireEvent.change(screen.getByLabelText("Repository"), { target: { value: "codefly-dev/module-saas-starter" }, }); - fireEvent.change(screen.getByLabelText("Paths (optional)"), { + fireEvent.change(screen.getByLabelText("Paths"), { target: { value: "docs/\nsrc/api/" }, }); - fireEvent.click( - screen.getByRole("button", { name: "Markdown only (.md)" }), - ); + fireEvent.change(screen.getByLabelText("File types"), { + target: { value: ".md" }, + }); fireEvent.change(screen.getByLabelText("Target collection"), { target: { value: "docs" }, }); - fireEvent.change(screen.getByLabelText("Access token"), { + fireEvent.change(screen.getByLabelText("Credential"), { target: { value: "ghp_token" }, }); - fireEvent.click( - screen.getByRole("button", { name: /^validate and connect$/i }), - ); + fireEvent.click(screen.getByRole("button", { name: /^connect source$/i })); - await waitFor(() => - expect(client.addGitHubSource).toHaveBeenCalledTimes(1), - ); - expect(client.addGitHubSource).toHaveBeenCalledWith({ + await waitFor(() => expect(client.addSource).toHaveBeenCalledTimes(1)); + expect(client.addSource).toHaveBeenCalledWith({ orgId: "org-1", - repo: "codefly-dev/module-saas-starter", - paths: ["docs/", "src/api/"], - fileExtensions: [".md"], - branch: "", + provider: repositoryProvider, + config: { + repo: "codefly-dev/module-saas-starter", + paths: "docs/\nsrc/api/", + file_extensions: ".md", + branch: "", + }, targetCollection: "docs", - accessToken: "ghp_token", - webhookSecret: "", + credential: { + kind: "static_secret", + secret: "ghp_token", + webhookSecret: "", + }, }); }); @@ -452,38 +461,32 @@ describe("DatasourcesPanel", () => { fireEvent.change(screen.getByLabelText("Target collection"), { target: { value: "docs" }, }); - fireEvent.change(screen.getByLabelText("Access token"), { + fireEvent.change(screen.getByLabelText("Credential"), { target: { value: "ghp_token" }, }); - fireEvent.click( - screen.getByRole("button", { name: /^validate and connect$/i }), - ); + fireEvent.click(screen.getByRole("button", { name: /^connect source$/i })); expect( - await screen.findByText(/acme\/handbook is connected and syncing/), + await screen.findByText(/Source is connected and syncing/), ).toBeTruthy(); // And it is an offer, not a gate: it can be put away. fireEvent.click(screen.getByRole("button", { name: "Not now" })); await waitFor(() => - expect( - screen.queryByText(/acme\/handbook is connected and syncing/), - ).toBeNull(), + expect(screen.queryByText(/Source is connected and syncing/)).toBeNull(), ); }); it("surfaces a connect failure in the form and keeps the dialog open", async () => { const client = fakeClient({ - addGitHubSource: vi.fn(async () => { - throw new Error("invalid access token"); + addSource: vi.fn(async () => { + throw new Error("Could not connect the source"); }), }); await openConnectForm(client); - fireEvent.click( - screen.getByRole("button", { name: /^validate and connect$/i }), - ); + fireEvent.click(screen.getByRole("button", { name: /^connect source$/i })); const alert = await screen.findByRole("alert"); - expect(alert.textContent).toContain("invalid access token"); + expect(alert.textContent).toContain("Could not connect the source"); // Dialog stays open so the user can correct the input, not silently vanish. expect(screen.getByLabelText("Repository")).toBeTruthy(); }); @@ -719,7 +722,7 @@ describe("DatasourcesPanel boundary column", () => { const client = fakeClient({ listSources: vi.fn(async () => [sampleSource]), listAccessibleScopes: vi.fn(async () => scopes), - addGitHubSource: vi.fn(async () => { + addSource: vi.fn(async () => { scopes = [ { nodeId: boundaryId, @@ -733,9 +736,7 @@ describe("DatasourcesPanel boundary column", () => { await openConnectForm(client); expect(await screen.findByText("11111111")).toBeTruthy(); - fireEvent.click( - screen.getByRole("button", { name: /^validate and connect$/i }), - ); + fireEvent.click(screen.getByRole("button", { name: /^connect source$/i })); await waitFor(() => expect(screen.getByText("Docs")).toBeTruthy()); }); @@ -1042,9 +1043,13 @@ describe("GitHub App onboarding", () => { afterEach(() => landOn("")); it("offers the App as the default and keeps the PAT as a named alternative", async () => { - renderWithClient(); - fireEvent.click( - await screen.findByRole("button", { name: /connect github/i }), + render( + {}} + />, ); const method = screen.getByLabelText("Authentication") as HTMLSelectElement; @@ -1061,9 +1066,12 @@ describe("GitHub App onboarding", () => { it("falls back to the PAT path when the client cannot drive App setup", async () => { // A consumer adapting its own client need not implement the App RPCs; the // surface must degrade to the token form rather than offer a dead button. - renderWithClient(); - fireEvent.click( - await screen.findByRole("button", { name: /connect github/i }), + render( + , ); const method = screen.getByLabelText("Authentication") as HTMLSelectElement; @@ -1075,82 +1083,34 @@ describe("GitHub App onboarding", () => { expect(screen.getByLabelText("Access token")).toBeTruthy(); }); - it("connects a public repository with no token and no webhook secret", async () => { + it("connects without a credential when the catalog offers that mode", async () => { const client = fakeClient(); - renderWithClient(); - fireEvent.click( - await screen.findByRole("button", { name: /connect github/i }), - ); - // A secret typed on the token path must not ride along after switching. - fireEvent.change(screen.getByLabelText("Webhook secret (optional)"), { - target: { value: "whsec" }, - }); - fireEvent.change(screen.getByLabelText("Authentication"), { - target: { value: "public" }, - }); - - // No token to paste, and no webhook to configure on a repository the - // tenant may not administer; the trade-off is spelled out instead. - expect(screen.queryByLabelText("Access token")).toBeNull(); - expect(screen.queryByLabelText("Webhook secret (optional)")).toBeNull(); - expect( - screen.getByText(/60 unauthenticated requests an hour/), - ).toBeTruthy(); - - fireEvent.change(screen.getByLabelText("Repository"), { - target: { value: "codefly-dev/module-saas-starter" }, - }); - fireEvent.change(screen.getByLabelText("Target collection"), { - target: { value: "docs" }, - }); - fireEvent.click( - screen.getByRole("button", { name: /^connect public repository$/i }), + await openConnectForm(client); + fireEvent.change( + screen.getByLabelText("Webhook signing secret (optional)"), + { target: { value: "example-secret" } }, ); - - await waitFor(() => - expect(client.addGitHubSource).toHaveBeenCalledTimes(1), + fireEvent.click(screen.getByRole("radio", { name: "No credential" })); + expect(screen.queryByLabelText("Credential")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + await waitFor(() => expect(client.addSource).toHaveBeenCalledTimes(1)); + expect(client.addSource).toHaveBeenCalledWith( + expect.objectContaining({ credential: { kind: "none" } }), ); - expect(client.addGitHubSource).toHaveBeenCalledWith({ - orgId: "org-1", - repo: "codefly-dev/module-saas-starter", - paths: [], - fileExtensions: [], - branch: "", - targetCollection: "docs", - accessToken: undefined, - webhookSecret: "", - }); }); - it("surfaces the host's refusal of a repository that is not public", async () => { + it("does not echo operation failures that may contain a credential", async () => { const client = fakeClient({ - addGitHubSource: vi.fn(async () => { - throw new ConnectError( - "No access token was supplied and that repository is not public.", - Code.FailedPrecondition, - ); + addSource: vi.fn(async () => { + throw new Error("example-secret is invalid"); }), }); - renderWithClient(); - fireEvent.click( - await screen.findByRole("button", { name: /connect github/i }), - ); - fireEvent.change(screen.getByLabelText("Authentication"), { - target: { value: "public" }, - }); - fireEvent.change(screen.getByLabelText("Repository"), { - target: { value: "acme/private" }, - }); - fireEvent.change(screen.getByLabelText("Target collection"), { - target: { value: "docs" }, - }); - fireEvent.click( - screen.getByRole("button", { name: /^connect public repository$/i }), + await openConnectForm(client); + fireEvent.click(screen.getByRole("button", { name: "Connect source" })); + expect((await screen.findByRole("alert")).textContent).toContain( + "Could not connect", ); - - const alert = await screen.findByRole("alert"); - expect(alert.textContent).toContain("not public"); - expect(screen.getByLabelText("Repository")).toBeTruthy(); + expect(document.body.textContent).not.toContain("example-secret"); }); it("sends the browser to the install URL the host minted", async () => { @@ -1161,8 +1121,9 @@ describe("GitHub App onboarding", () => { fireEvent.click( await screen.findByRole("button", { name: /connect github/i }), ); + fireEvent.click(screen.getByRole("radio", { name: "Organization app" })); fireEvent.click( - screen.getByRole("button", { name: /install or select repositories/i }), + screen.getByRole("button", { name: "Install organization app" }), ); await waitFor(() => @@ -1553,7 +1514,7 @@ describe("GitHub App onboarding", () => { // Reopening to connect a second repository the same installation grants. fireEvent.click( - await screen.findByRole("button", { name: /connect github/i }), + await screen.findByRole("button", { name: /Choose another repository/i }), ); const option = (await screen.findByRole("option", { @@ -1595,17 +1556,13 @@ describe("GitHub App onboarding", () => { // begin and complete are independently optional. Offering the install with // no way to redeem what comes back strands the tenant on a completed // GitHub install with nothing to show for it. - const client = fakeClient({ - beginGitHubAppSetup: vi.fn(async () => ({ - installUrl: - "https://github.com/apps/codefly/installations/new?state=s1", - state: "s1", - expiresAt: undefined, - })), - }); - renderWithClient(); - fireEvent.click( - await screen.findByRole("button", { name: /connect github/i }), + + render( + , ); const method = screen.getByLabelText("Authentication") as HTMLSelectElement; 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..5c3125a61 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 @@ -7,6 +7,8 @@ import { DatasourcesPanel } from "../datasources-panel.js"; import { createDatasourceClient } from "../gateway.js"; import { onSourceSyncRequested } from "../sync-requests.js"; +import { repositoryProvider } from "../../../stories/catalog-fixtures.js"; + const gatewayCatalog = JSON.parse( readFileSync( resolve( @@ -137,6 +139,15 @@ describe("createDatasourceClient", () => { contentResource: "example-records", }); const operations = { + getCatalog: () => client.getCatalog!(), + addSource: () => + client.addSource!({ + orgId: "org-1", + provider: repositoryProvider, + config: { repo: "acme/example" }, + targetCollection: "Example", + credential: { kind: "none" }, + }), listSources: () => client.listSources("org-1"), listAccessibleScopes: () => client.listAccessibleScopes!("org-1"), listActivity: () => client.listActivity!("org-1", "ds-1"), @@ -150,13 +161,11 @@ describe("createDatasourceClient", () => { webhookSecret: "", }), addPersonalApiSource: () => - client - .addPersonalApiSource!({ - orgId: "org-1", - baseUrl: "https://api.example.com", - targetCollection: "Example", - }) - .catch(() => undefined), + client.addPersonalApiSource!({ + orgId: "org-1", + baseUrl: "https://api.example.com", + targetCollection: "Example", + }).catch(() => undefined), declareSourceOperations: () => client.declareSourceOperations!("org-1", "ds-1", []), beginGitHubAppSetup: () => client.beginGitHubAppSetup!("org-1"), @@ -164,7 +173,8 @@ describe("createDatasourceClient", () => { client.completeGitHubAppSetup!("org-1", "state-1", "42", "oauth-1"), migrateGitHubSourceToApp: () => client.migrateGitHubSourceToApp!("org-1", "ds-1"), - reconcileSource: () => client.reconcileSource("org-1", "ds-1", "effect-1"), + reconcileSource: () => + client.reconcileSource("org-1", "ds-1", "effect-1"), lookupReconcileSource: () => client.lookupReconcileSource("effect-1"), reconnectSource: () => client.reconnectSource!("org-1", "ds-1"), getSourceSync: () => client.getSourceSync!("org-1", "ds-1"), @@ -180,18 +190,27 @@ describe("createDatasourceClient", () => { // The stub answers {} to everything; an operation whose answer must // carry a record rejects on it, after the call this test counts. beginAccountLink: () => - client.beginAccountLink!("org-1", "github", "https://host.example.com/"), + client.beginAccountLink!( + "org-1", + "github", + "https://host.example.com/", + ), completeAccountLink: () => - client.completeAccountLink!("org-1", "dl.s", "c").catch(() => undefined), + client.completeAccountLink!("org-1", "dl.s", "c").catch( + () => undefined, + ), listMyAccountLinks: () => client.listMyAccountLinks!("org-1"), deleteAccountLink: () => client.deleteAccountLink!("org-1", "l1"), getDirectory: () => client.getDirectory!("org-1"), bindGroup: () => - client.bindGroup!("org-1", "github", "acme/x", "t1").catch(() => undefined), + client.bindGroup!("org-1", "github", "acme/x", "t1").catch( + () => undefined, + ), unbindGroup: () => client.unbindGroup!("org-1", "b1"), claimDomain: () => client.claimDomain!("org-1", "example.com").catch(() => undefined), - verifyDomain: () => client.verifyDomain!("org-1", "d1").catch(() => undefined), + verifyDomain: () => + client.verifyDomain!("org-1", "d1").catch(() => undefined), deleteDomain: () => client.deleteDomain!("org-1", "d1"), } satisfies Partial Promise>>; expect(Object.keys(operations).sort()).toEqual(Object.keys(client).sort()); @@ -203,7 +222,10 @@ describe("createDatasourceClient", () => { }); it("announces every sync it enqueues, so a progress view watches closely at once", async () => { - stubFetchSequence([reply({ jobId: "job-1" }), reply({ datasource: { id: "ds-new" } })]); + stubFetchSequence([ + reply({ jobId: "job-1" }), + reply({ datasource: { id: "ds-new" } }), + ]); const client = createDatasourceClient({ apiBase: "/api/solutions/guides/proxy", getAccessToken: () => "test-token", @@ -289,7 +311,12 @@ describe("createDatasourceClient", () => { progress: { phase: "SOURCE_SYNC_PHASE_DONE", trigger: "SOURCE_SYNC_TRIGGER_MANUAL", - changes: { files: 42, splitKnown: true, snapshot: true, commit: "c1" }, + changes: { + files: 42, + splitKnown: true, + snapshot: true, + commit: "c1", + }, }, }), reply({ code: "not_found", message: "datasource: sync not found" }, 404), @@ -311,7 +338,9 @@ describe("createDatasourceClient", () => { snapshot: true, commit: "c1", }); - await expect(client.getSourceSync!("org-1", "ds-2")).resolves.toBeUndefined(); + await expect( + client.getSourceSync!("org-1", "ds-2"), + ).resolves.toBeUndefined(); }); it("calls the live DatasourceService through the gateway with the host token", async () => { @@ -427,7 +456,11 @@ describe("createDatasourceClient", () => { const [source] = oneSource.datasources; stubFetch({ datasources: [ - { ...source, conformant: false, conformanceGap: "no cursor and no deletions" }, + { + ...source, + conformant: false, + conformanceGap: "no cursor and no deletions", + }, ], }); const client = createDatasourceClient({ @@ -558,7 +591,19 @@ describe("DatasourcesPanel gateway binding", () => { ])( "offers %s the management controls only when their credential names the tier", async (_who, claims, manages) => { - stubFetch(oneSource); + stubFetch({ + ...oneSource, + providers: [ + { + provider: 1, + connector: "github", + displayName: "GitHub", + acceptsNewSources: true, + credentialModes: [1], + configFields: [], + }, + ], + }); render( { />, ); await screen.findByText("codefly-dev/module-saas-starter"); + if (manages) + await screen.findByRole("button", { name: "Connect GitHub" }); expect(!!screen.queryByRole("button", { name: "Connect GitHub" })).toBe( manages, ); @@ -809,7 +856,12 @@ it("asks the permission service for the resource the composition declared", asyn it("reports which scopes the viewer reads only as a platform administrator", async () => { stubFetch({ scopes: [ - { nodeId: "granted", label: "Granted", kind: "collection", basis: "ACCESS_BASIS_GRANT" }, + { + nodeId: "granted", + label: "Granted", + kind: "collection", + basis: "ACCESS_BASIS_GRANT", + }, { nodeId: "platform", label: "Platform", @@ -827,7 +879,9 @@ it("reports which scopes the viewer reads only as a platform administrator", asy }); const scopes = await client.listAccessibleScopes!("org-1"); expect( - Object.fromEntries(scopes.map((scope) => [scope.nodeId, scope.viaPlatformAdministrator])), + Object.fromEntries( + scopes.map((scope) => [scope.nodeId, scope.viaPlatformAdministrator]), + ), ).toEqual({ granted: false, platform: true, unstated: false }); }); @@ -894,7 +948,9 @@ describe("generic OAuth api sources", () => { baseUrl: "https://api.example.com", targetCollection: "Example API", }); - expect(calls[0].url).toContain("saas.accounts.v1.DatasourceService/AddSource"); + expect(calls[0].url).toContain( + "saas.accounts.v1.DatasourceService/AddSource", + ); expect(calls[0].body).toEqual({ orgId: "org-1", provider: "DATASOURCE_PROVIDER_API", @@ -923,7 +979,10 @@ describe("generic OAuth api sources", () => { { id: "a", provider: "DATASOURCE_PROVIDER_API", - api: { baseUrl: "https://a.example.com", credentialKind: "API_CREDENTIAL_KIND_BEARER" }, + api: { + baseUrl: "https://a.example.com", + credentialKind: "API_CREDENTIAL_KIND_BEARER", + }, }, { id: "b", diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/catalog.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/catalog.ts new file mode 100644 index 000000000..3088dd2d3 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/catalog.ts @@ -0,0 +1,147 @@ +import { + type DescField, + fromJson, + type JsonObject, + type MessageShape, + ScalarType, +} from "@bufbuild/protobuf"; +import { DatasourceService } from "@codefly-dev/saas-sdk"; +import type { + ConnectSourceInput, + DatasourceConnectField, + DatasourceProviderView, +} from "./types.js"; + +const request = DatasourceService.method.addSource.input; +type Provider = MessageShape< + typeof DatasourceService.method.getDatasourceCatalog.output +>["providers"][number]; + +// The provider's registry key names its config oneof. The SDK, not a second +// provider table, owns field types and wire encoding. +function configField(connector: string) { + return request.oneofs + .find((oneof) => oneof.name === "config") + ?.fields.find((field) => field.name === connector); +} +function format(field?: DescField): DatasourceConnectField["format"] { + if (field?.fieldKind === "scalar" && field.scalar === ScalarType.STRING) + return "text"; + if ( + field?.fieldKind === "list" && + field.listKind === "scalar" && + field.scalar === ScalarType.STRING + ) + return "lines"; + return "json"; +} +export function catalogProvider(provider: Provider): DatasourceProviderView { + const config = configField(provider.connector); + const fields = config?.fieldKind === "message" ? config.message.fields : []; + const modes = + DatasourceService.method.getDatasourceCatalog.output.fields.find( + (field) => field.name === "providers", + ); + const mode = + modes?.fieldKind === "list" && modes.listKind === "message" + ? modes.message.fields.find((field) => field.name === "credential_modes") + : undefined; + const credentialModes = provider.credentialModes.map((value) => + mode?.fieldKind === "list" && mode.listKind === "enum" + ? (mode.enum.values.find((entry) => entry.number === value)?.name ?? "") + .replace("DATASOURCE_CREDENTIAL_MODE_", "") + .toLowerCase() + : "", + ); + return { + provider: provider.provider, + connector: provider.connector, + displayName: provider.displayName, + description: provider.description, + acceptsNewSources: provider.acceptsNewSources, + gap: provider.conformanceGap, + supportsWebhook: provider.supportsWebhook, + // A credential mode alone does not promise an installation workflow. + // Require both SDK operations for the catalog connector as well. + supportsOrganizationAppSetup: + credentialModes.includes("org_app") && + ["begin", "complete"].every((leg) => + DatasourceService.methods.some( + (method) => + method.name.toLowerCase() === + `${leg}${provider.connector.toLowerCase()}appsetup`, + ), + ), + credentialModes, + fields: provider.configFields.map((field) => ({ + key: field.key, + displayName: field.displayName, + help: field.help, + required: field.required, + format: format(fields.find((entry) => entry.name === field.key)), + })), + ...(!config || + provider.configFields.some( + (field) => !fields.some((entry) => entry.name === field.key), + ) + ? { + unsupportedReason: + "This host's provider fields require a newer client kit.", + } + : {}), + }; +} + +export function sourceRequest( + input: ConnectSourceInput, +): MessageShape { + if (!input.provider.acceptsNewSources) + throw new Error("Provider does not accept new sources."); + if (input.provider.unsupportedReason) + throw new Error(input.provider.unsupportedReason); + const config = configField(input.provider.connector); + if (config?.fieldKind !== "message") + throw new Error("Unsupported provider configuration."); + const values: JsonObject = {}; + for (const field of input.provider.fields) { + const raw = input.config[field.key] ?? ""; + if (field.required && !raw.trim()) + throw new Error(`${field.displayName} is required.`); + if (!raw.trim()) continue; + const wire = config.message.fields.find( + (entry) => entry.name === field.key, + ); + if (!wire) throw new Error("Unsupported provider field."); + try { + values[wire.jsonName] = + format(wire) === "text" + ? raw + : format(wire) === "lines" + ? raw + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + : JSON.parse(raw); + } catch { + throw new Error(`${field.displayName} must contain valid JSON.`); + } + } + const credential = input.credential; + // fromJson validates and produces the generated typed request, including the + // selected oneof. Unknown fields are never silently dropped. + return fromJson(request, { + orgId: input.orgId, + provider: input.provider.provider, + [config.jsonName]: values, + ...(input.boundaryNodeId + ? { boundaryNodeId: input.boundaryNodeId } + : { collectionLabel: input.targetCollection }), + ...(credential.kind === "static_secret" + ? { + credential: credential.secret, + webhookSecret: credential.webhookSecret ?? "", + oauth2ClientSecret: credential.oauth2ClientSecret ?? "", + } + : {}), + }); +} diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx index 88f35524c..4512cd87e 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/connect-github-form.tsx @@ -104,7 +104,11 @@ export function ConnectGitHubForm({
{ + form.setValue("accessToken", ""); + form.setValue("webhookSecret", ""); + onSubmit(values); + })} className="space-y-4" noValidate > diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/connect-source.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/connect-source.tsx new file mode 100644 index 000000000..2754af1f9 --- /dev/null +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/connect-source.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { + Button, + Card, + Input, + Label, + Radio, + RadioGroup, + Spinner, + Textarea, + useLoadingPhase, +} from "@codefly-dev/ui/layout"; +import { useQuery } from "@tanstack/react-query"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import type { + ConnectSourceInput, + DatasourceClient, + DatasourceCredential, + DatasourceProviderView, +} from "./types.js"; + +const modes = { + none: "No credential", + org_app: "Organization app", + static_secret: "Access credential", +} as const; + +export function useDatasourceCatalog(client: DatasourceClient, orgId: string) { + return useQuery({ + queryKey: ["datasource-catalog", orgId], + queryFn: () => client.getCatalog!(), + enabled: !!client.getCatalog, + retry: false, + refetchInterval: 15000, + }); +} + +/** The catalog is read even with no sources; a refused provider remains visible. */ +export function ConnectSource({ + client, + orgId, + onConnected, + onBeginAppSetup, + appSetup, +}: { + client: DatasourceClient; + orgId: string; + onConnected: ( + input: Pick, + ) => void; + onBeginAppSetup?: () => Promise; + appSetup?: ReactNode; +}) { + const [selected, setSelected] = useState(); + const catalog = useDatasourceCatalog(client, orgId); + const loading = useLoadingPhase(!!client.getCatalog && catalog.isPending); + if (!client.getCatalog) + return ( +

+ Provider catalog unavailable with this client. +

+ ); + if (loading.quiet) return null; + if (loading.indicator) return ; + if (catalog.isError) + return ( +
+ Could not load the provider catalog.{" "} + +
+ ); + return ( +
+ {catalog.data?.length === 0 && ( +

The host reports no source providers.

+ )} + {catalog.data?.map((provider) => ( + +

+ {provider.description} +

+ {!provider.acceptsNewSources ? ( +
+

Not accepting new sources.

+

{provider.gap}

+
+ ) : provider.unsupportedReason ? ( +

+ {provider.unsupportedReason} +

+ ) : !client.addSource ? ( +

Connecting requires a newer client.

+ ) : appSetup && provider.supportsOrganizationAppSetup ? ( + appSetup + ) : selected === provider.connector ? ( + setSelected(undefined)} + onBeginAppSetup={ + provider.supportsOrganizationAppSetup + ? onBeginAppSetup + : undefined + } + onConnected={(boundary) => { + setSelected(undefined); + onConnected(boundary); + }} + /> + ) : ( + + )} +
+ ))} +
+ ); +} + +function ProviderForm({ + provider, + client, + orgId, + onCancel, + onConnected, + onBeginAppSetup, +}: { + provider: DatasourceProviderView; + client: DatasourceClient; + orgId: string; + onCancel: () => void; + onConnected: ( + input: Pick, + ) => void; + onBeginAppSetup?: () => Promise; +}) { + const supported = provider.credentialModes.filter( + (mode): mode is keyof typeof modes => Object.hasOwn(modes, mode), + ); + const [mode, setMode] = useState(supported[0]); + const [pending, setPending] = useState(false); + const busy = useRef(false); + const mounted = useRef(false); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [error, setError] = useState(); + if (!mode) + return ( +

+ This provider's authentication requires a newer client. +

+ ); + return ( + { + event.preventDefault(); + if (busy.current) return; + busy.current = true; + setPending(true); + setError(undefined); + const form = event.currentTarget; + const fieldValue = (name: string) => + (form.elements.namedItem(name) as HTMLInputElement | null)?.value ?? + ""; + // Secrets live only in uncontrolled password inputs until this call. Clear + // those inputs before invoking any code that could throw or remain pending. + let credential: DatasourceCredential = + mode === "static_secret" + ? { + kind: mode, + secret: fieldValue("secret"), + webhookSecret: fieldValue("webhookSecret"), + } + : { kind: mode }; + for (const input of form.querySelectorAll( + 'input[type="password"]', + )) + input.value = ""; + const boundary = { targetCollection: fieldValue("collection") }; + try { + await client.addSource!({ + orgId, + provider, + ...boundary, + credential, + config: Object.fromEntries( + provider.fields.map((field) => [ + field.key, + fieldValue(`config:${field.key}`), + ]), + ), + }); + if (mounted.current) onConnected(boundary); + } catch { + // A transport error can echo submitted bytes. Never retain or render it. + setError( + "Could not connect the source. Check its fields and access, then enter the credential again to retry.", + ); + } finally { + credential = { kind: "none" }; + busy.current = false; + setPending(false); + } + }} + > +
+ {provider.fields.map((field) => ( +
+