From bbff9fb008c498d82c62fa5752c2b9904d60cc9a Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:55:12 -0400 Subject: [PATCH 1/3] feat(saas-ui): the datasources panel offers what the connector catalog admits and names the rest The panel reads GetDatasourceCatalog through the client's optional getCatalog: a provider that accepts new sources gets its connect action, and every one that takes none is listed with the host's gap sentence. A client without the catalog, or whose read failed, offers GitHub alone as before. An object-storage connect form joins the GitHub one, sharing an extracted CollectionField; it is offered only once the catalog admits the provider. Refs #1211 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/no-raw-type-primitives.test.ts | 2 +- .../frontend/code/packages/saas-ui/README.md | 21 ++- .../__tests__/datasources-panel.test.tsx | 129 +++++++++++++ .../datasources/__tests__/gateway.test.tsx | 27 ++- .../src/datasources/collection-field.tsx | 112 +++++++++++ .../src/datasources/connect-github-form.tsx | 95 ++-------- .../connect-object-storage-form.tsx | 177 ++++++++++++++++++ .../src/datasources/datasources-panel.tsx | 109 ++++++++++- .../saas-ui/src/datasources/gateway.ts | 33 ++++ .../saas-ui/src/datasources/queries.ts | 31 +++ .../saas-ui/src/datasources/schema.ts | 27 +++ .../packages/saas-ui/src/datasources/types.ts | 43 +++++ .../code/packages/saas-ui/src/index.ts | 7 + .../saas-ui/stories/datasources.stories.tsx | 21 +++ .../services/frontend/code/ui-inventory.json | 49 +++++ 15 files changed, 795 insertions(+), 88 deletions(-) create mode 100644 module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx create mode 100644 module/services/frontend/code/packages/saas-ui/src/datasources/connect-object-storage-form.tsx diff --git a/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts b/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts index 5d4c3d36b..790d6254b 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts @@ -78,7 +78,7 @@ const SAAS_UI_BASELINE: Record = { // Lowered from 16 when the credential block moved into // `datasources/credential-mode.tsx`, which carries none: the shared copy // names its slots, so the debt left behind shrank rather than moving. - "datasources/connect-github-form.tsx": 11, + "datasources/connect-github-form.tsx": 10, "datasources/datasources-panel.tsx": 15, }; const saasUiDir = resolve(srcDir, "../../saas-ui/src"); diff --git a/module/services/frontend/code/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md index 428c2bcca..f9b37be1b 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,21 @@ 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 — **Connect + GitHub**, and **Connect object storage** when the client also implements + `addObjectStorageSource` — 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. +- `` — endpoint (https only), region, + bucket, prefix, access key id, the secret access key the host encrypts at + receipt, and the target collection. It says what a bucket cannot: every object + is readable by whoever reads the collection, so one prefix per audience. - `` — 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 +401,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`. + `useAddObjectStorageSource`, `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/src/datasources/__tests__/datasources-panel.test.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/__tests__/datasources-panel.test.tsx index 9d7190749..9b8cff61e 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,132 @@ 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]), + addObjectStorageSource: vi.fn(async () => {}), + }); + 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(); + expect( + screen.queryByRole("button", { name: "Connect object storage" }), + ).toBeNull(); + }); + + 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(); + }); + + it("connects a bucket the catalog admits, with its key as the credential", async () => { + const addObjectStorageSource = vi.fn(async () => {}); + const client = fakeClient({ + getCatalog: vi.fn(async () => [ + github, + { ...objectStorage, acceptsNewSources: true, gap: "" }, + ]), + addObjectStorageSource, + }); + renderWithClient(); + + fireEvent.click( + await screen.findByRole("button", { name: "Connect object storage" }), + ); + const fill = (label: string, value: string) => + fireEvent.change(screen.getByLabelText(label), { target: { value } }); + fill("Endpoint", "https://store.example.com"); + fill("Region", "us-east-1"); + fill("Bucket", "acme-docs"); + fill("Prefix (optional)", "handbook/"); + fill("Access key ID", "AKIAEXAMPLE"); + fill("Secret access key", "example-secret"); + fill("Target collection", "Handbook"); + fireEvent.click(screen.getByRole("button", { name: "Connect bucket" })); + + await waitFor(() => + expect(addObjectStorageSource).toHaveBeenCalledWith({ + orgId: "org-1", + endpoint: "https://store.example.com", + region: "us-east-1", + bucket: "acme-docs", + prefix: "handbook/", + accessKeyId: "AKIAEXAMPLE", + secretAccessKey: "example-secret", + targetCollection: "Handbook", + boundaryNodeId: undefined, + }), + ); + await waitFor(() => + expect( + screen.queryByRole("dialog", { name: "Connect object storage" }), + ).toBeNull(), + ); + }); + + it("refuses a store that is not https before asking the host", async () => { + const addObjectStorageSource = vi.fn(async () => {}); + const client = fakeClient({ + getCatalog: vi.fn(async () => [ + { ...objectStorage, acceptsNewSources: true, gap: "" }, + ]), + addObjectStorageSource, + }); + renderWithClient(); + + fireEvent.click( + await screen.findByRole("button", { name: "Connect object storage" }), + ); + fireEvent.change(screen.getByLabelText("Endpoint"), { + target: { value: "http://store.example.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Connect bucket" })); + + expect(await screen.findByText("Use the store's https URL")).toBeTruthy(); + expect(addObjectStorageSource).not.toHaveBeenCalled(); + }); +}); 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..c64f866a6 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,18 @@ describe("createDatasourceClient", () => { expected: { branch: "main", paths: [], fileExtensions: [] }, }), deleteSource: () => client.deleteSource("org-1", "ds-1"), + getCatalog: () => client.getCatalog!(), + addObjectStorageSource: () => + client.addObjectStorageSource!({ + orgId: "org-1", + endpoint: "https://store.example.com", + region: "us-east-1", + bucket: "acme-docs", + prefix: "kb/", + accessKeyId: "AKIAEXAMPLE", + secretAccessKey: "secret", + targetCollection: "Example", + }), // 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 +581,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( string; + collections?: CollectionAccessView[]; + readableNodeIds?: string[]; + collectionError?: boolean; + boundaryNodeId?: string; + targetCollection?: string; + /** An existing collection was picked, or "" to create one. */ + onSelect: (nodeId: string, label: string) => void; + inputProps: UseFormRegisterReturn; + error?: string; +} + +/** + * Where a new source's content lands: an existing collection, or a label to + * create one under. Every connect form shares it, so who can read the result + * is told the same way whichever provider is being connected. + */ +export function CollectionField({ + idFor, + collections, + readableNodeIds, + collectionError, + boundaryNodeId, + targetCollection, + onSelect, + inputProps, + error, +}: CollectionFieldProps) { + const selected = collections?.find( + (collection) => collection.nodeId === boundaryNodeId, + ); + const named = + selected ?? + collections?.find((collection) => collection.label === targetCollection); + return ( +
+ {collections && ( + <> + + + + )} + + +

+ Connecting grants no read access to the creator or a default team. + Organization administrators explicitly grant members or teams read + access. Platform administrators read every collection without a grant. +

+ {collectionError ? ( +

Couldn’t inspect collection grants.

+ ) : named ? ( +

+ Readers:{" "} + {named.grants.map((grant) => grant.subjectLabel).join(", ") || + "No collection read grants"} + .{" "} + {readableNodeIds === undefined + ? "Your read permission is unresolved." + : readableNodeIds.includes(named.nodeId) + ? "You can read this collection." + : "You do not have read access. Ask an administrator for access."} +

+ ) : ( +

+ {collections + ? "New collections have no read grants. Your read permission must be checked after creation." + : "Collection grants are unresolved. Inspect them in the host after connecting."} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ ); +} 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..8d9cdc20b 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 @@ -4,6 +4,7 @@ import { Button, Input, Label, Textarea } from "@codefly-dev/ui/layout"; import { zodResolver } from "@hookform/resolvers/zod"; import { useId } from "react"; import { useForm, useWatch } from "react-hook-form"; +import { CollectionField } from "./collection-field.js"; import { AccessTokenField, AppInstallPrompt, @@ -78,12 +79,6 @@ export function ConnectGitHubForm({ control: form.control, name: "targetCollection", }); - const selected = collections?.find( - (collection) => collection.nodeId === boundaryNodeId, - ); - const named = - selected ?? - collections?.find((collection) => collection.label === targetCollection); return (
-
- {collections && ( - <> - - - - )} - - -

- Connecting grants no read access to the creator or a default team. - Organization administrators explicitly grant members or teams read - access. Platform administrators read every collection without a - grant. -

- {collectionError ? ( -

Couldn’t inspect collection grants.

- ) : named ? ( -

- Readers:{" "} - {named.grants.map((grant) => grant.subjectLabel).join(", ") || - "No collection read grants"} - .{" "} - {readableNodeIds === undefined - ? "Your read permission is unresolved." - : readableNodeIds.includes(named.nodeId) - ? "You can read this collection." - : "You do not have read access. Ask an administrator for access."} -

- ) : ( -

- {collections - ? "New collections have no read grants. Your read permission must be checked after creation." - : "Collection grants are unresolved. Inspect them in the host after connecting."} -

- )} - {errors.targetCollection && ( -

- {errors.targetCollection.message} -

- )} -
+ { + form.setValue("boundaryNodeId", nodeId); + form.setValue("targetCollection", label); + }} + inputProps={form.register("targetCollection")} + {...(errors.targetCollection?.message + ? { error: errors.targetCollection.message } + : {})} + /> {method === "pat" && ( void; + onCancel: () => void; + isPending: boolean; + errorMessage?: string; +} + +const textFields = [ + { + name: "endpoint", + label: "Endpoint", + placeholder: "https://s3.us-east-1.amazonaws.com", + }, + { name: "region", label: "Region", placeholder: "us-east-1" }, + { name: "bucket", label: "Bucket", placeholder: "acme-knowledge" }, + { name: "prefix", label: "Prefix (optional)", placeholder: "handbook/" }, + { name: "accessKeyId", label: "Access key ID", placeholder: "" }, +] as const; + +export function ConnectObjectStorageForm({ + collections, + readableNodeIds, + collectionError, + onSubmit, + onCancel, + isPending, + errorMessage, +}: ConnectObjectStorageFormProps) { + const fieldId = useId(); + const idFor = (name: string) => `${fieldId}-${name}`; + const form = useForm({ + resolver: zodResolver(connectObjectStorageSchema), + defaultValues: { + endpoint: "", + region: "", + bucket: "", + prefix: "", + accessKeyId: "", + secretAccessKey: "", + boundaryNodeId: "", + targetCollection: "", + }, + }); + const { errors } = form.formState; + const boundaryNodeId = useWatch({ + control: form.control, + name: "boundaryNodeId", + }); + const targetCollection = useWatch({ + control: form.control, + name: "targetCollection", + }); + + return ( +
+
+
+

+ Connect object storage +

+

+ Register a bucket as a data source. The objects under its prefix are + pulled on sync, and only what changed is sent again. Everyone who + may read the collection reads every object: a bucket has no + per-object access list, so use one prefix per audience. +

+
+ +
+ {textFields.map((field) => { + const error = errors[field.name]?.message; + return ( +
+ + + {error && ( +

+ {error} +

+ )} +
+ ); + })} + +
+ + +

+ Encrypted when the host receives it and never shown again. Give + the key read and list access to this bucket only. +

+ {errors.secretAccessKey && ( +

+ {errors.secretAccessKey.message} +

+ )} +
+ + { + form.setValue("boundaryNodeId", nodeId); + form.setValue("targetCollection", label); + }} + inputProps={form.register("targetCollection")} + {...(errors.targetCollection?.message + ? { error: errors.targetCollection.message } + : {})} + /> + + {errorMessage && ( +

+ {errorMessage} +

+ )} + +
+ + +
+ +
+
+ ); +} diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx index d84e210f1..c2f3b7e65 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx @@ -39,6 +39,7 @@ import { } from "../solution/viewer.js"; import { CollectionGrants } from "./collection-access.js"; import { ConnectGitHubForm } from "./connect-github-form.js"; +import { ConnectObjectStorageForm } from "./connect-object-storage-form.js"; import { EditSourceForm } from "./edit-source-form.js"; import { messageOf } from "./errors.js"; import { createDatasourceClient, type GatewayBinding } from "./gateway.js"; @@ -46,13 +47,19 @@ import { readAppSetupReturn, scrubAppSetupReturn } from "./github-app-setup.js"; import { useAccessibleScopes, useAddGitHubSource, + useAddObjectStorageSource, + useDatasourceCatalog, useDeleteSource, useListSources, useReconcileSource, useUpdateSource, } from "./queries.js"; import type { AdmittedReconcilePass } from "./reconcile-passes.js"; -import type { ConnectGitHubValues, EditSourceValues } from "./schema.js"; +import type { + ConnectGitHubValues, + ConnectObjectStorageValues, + EditSourceValues, +} from "./schema.js"; import { SourceSyncProgress, useSourceSync } from "./sync-progress.js"; import type { AccessibleScopeView, @@ -327,6 +334,51 @@ function DatasourcesPanelView({ ) : undefined; const addMutation = useAddGitHubSource(client); + const addObjectStorageMutation = useAddObjectStorageSource(client); + const [connectingObjectStorage, setConnectingObjectStorage] = useState(false); + // The catalog decides what is offered: a provider that accepts new sources + // gets its connect form, if this kit has one, and every other provider is + // named with the host's reason it takes none. A client that does not read the + // catalog — or whose read failed — offers GitHub alone, as before the catalog + // was read; the host refuses a provider it does not admit either way. + const catalog = useDatasourceCatalog(client); + const accepts = (connector: string) => + !!catalog.data?.some( + (provider) => + provider.connector === connector && provider.acceptsNewSources, + ); + const offerGitHub = catalog.isSuccess ? accepts("github") : true; + const offerObjectStorage = + catalog.isSuccess && accepts("upload") && !!client.addObjectStorageSource; + const refusedProviders = catalog.isSuccess + ? catalog.data.filter((provider) => !provider.acceptsNewSources) + : []; + + const handleConnectObjectStorage = (values: ConnectObjectStorageValues) => { + addObjectStorageMutation.mutate( + { + orgId, + endpoint: values.endpoint, + region: values.region, + bucket: values.bucket, + prefix: values.prefix, + accessKeyId: values.accessKeyId, + secretAccessKey: values.secretAccessKey, + targetCollection: values.targetCollection, + boundaryNodeId: values.boundaryNodeId || undefined, + }, + { + onSuccess: () => { + setConnectingObjectStorage(false); + setJustConnected({ + repo: `${values.bucket}/${values.prefix}`, + nodeId: values.boundaryNodeId || undefined, + label: values.targetCollection, + }); + }, + }, + ); + }; const reconcileMutation = useReconcileSource(client); const deleteMutation = useDeleteSource(client); const updateMutation = useUpdateSource(client); @@ -599,12 +651,40 @@ function DatasourcesPanelView({ Repositories this organization ingests from.

- {canManage && ( - + {canManage && (offerGitHub || offerObjectStorage) && ( +
+ {offerGitHub && ( + + )} + {offerObjectStorage && ( + + )} +
)} + {canManage && refusedProviders.length > 0 && ( +
    + {refusedProviders.map((provider) => ( +
  • + {provider.displayName} takes no new sources to sync yet + {provider.gap ? `: ${provider.gap}.` : "."} + {provider.acceptsOperations && + " Its operations can still be connected."} +
  • + ))} +
+ )} {scopes.isError ? (

@@ -937,6 +1017,25 @@ function DatasourcesPanelView({ } /> )} + {canManage && connectingObjectStorage && ( + setConnectingObjectStorage(false)} + isPending={addObjectStorageMutation.isPending} + errorMessage={ + addObjectStorageMutation.isError + ? messageOf(addObjectStorageMutation.error) + : undefined + } + /> + )} {canManage && showConnect && ( ({ + connector: provider.connector, + displayName: provider.displayName, + description: provider.description, + acceptsNewSources: provider.acceptsNewSources, + gap: provider.acceptsNewSources ? "" : provider.conformanceGap, + acceptsOperations: provider.acceptsOperations, + })); + }, + async addObjectStorageSource(input) { + const response = await client.addSource({ + orgId: input.orgId, + provider: DatasourceProvider.UPLOAD, + config: { + case: "upload", + value: { + endpoint: input.endpoint, + region: input.region, + bucket: input.bucket, + prefix: input.prefix, + accessKeyId: input.accessKeyId, + }, + }, + credential: input.secretAccessKey, + boundary: input.boundaryNodeId + ? { case: "boundaryNodeId", value: input.boundaryNodeId } + : { case: "collectionLabel", value: input.targetCollection }, + }); + // The host starts every provider's first sync as it connects it. + notifySourceSyncRequested(response.datasource?.id ?? ""); + }, 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..9952e5c16 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 @@ -7,6 +7,7 @@ import { } from "./reconcile-passes.js"; import type { ConnectGitHubInput, + ConnectObjectStorageInput, DatasourceClient, EditSourceInput, SourceReconcileReceipt, @@ -38,6 +39,36 @@ 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 useAddObjectStorageSource(client: DatasourceClient) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: ConnectObjectStorageInput) => + client.addObjectStorageSource!(input), + onSuccess: (_result, input) => { + queryClient.invalidateQueries({ queryKey: sourcesKey(input.orgId) }); + queryClient.invalidateQueries({ + queryKey: ["collection-access", input.orgId], + }); + queryClient.invalidateQueries({ queryKey: scopesKey(input.orgId) }); + }, + }); +} + export function useAddGitHubSource(client: DatasourceClient) { const queryClient = useQueryClient(); return useMutation({ diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/schema.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/schema.ts index ef685af9a..e3084a8e2 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/schema.ts +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/schema.ts @@ -92,3 +92,30 @@ export const connectGitHubSchema = z }); export type ConnectGitHubValues = z.infer; + +export const connectObjectStorageSchema = z.object({ + endpoint: z + .string() + .max(2048, "Endpoint too long") + .regex(/^https:\/\/[^\s/]+/, "Use the store's https URL"), + region: z.string().min(1, "Region is required").max(64, "Region too long"), + bucket: z.string().min(1, "Bucket is required").max(255, "Bucket too long"), + prefix: z.string().max(1024, "Prefix too long"), + accessKeyId: z + .string() + .min(1, "Access key ID is required") + .max(256, "Access key ID too long"), + secretAccessKey: z + .string() + .min(1, "Secret access key is required") + .max(4096, "Secret access key too long"), + boundaryNodeId: z.string().optional(), + targetCollection: z + .string() + .min(1, "Target collection is required") + .max(255, "Target collection too long"), +}); + +export type ConnectObjectStorageValues = z.infer< + typeof connectObjectStorageSchema +>; 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..124ab9632 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,42 @@ export interface ConnectGitHubInput { webhookSecret: string; } +/** + * An object-storage source, ready for `addObjectStorageSource`: a bucket and a + * key prefix in a store, and the access key the host encrypts at receipt. + */ +export interface ConnectObjectStorageInput { + orgId: string; + endpoint: string; + region: string; + bucket: string; + prefix: string; + accessKeyId: string; + secretAccessKey: string; + targetCollection: string; + boundaryNodeId?: 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 +455,13 @@ 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; + /** Connects an object-storage source. Optional, like the catalog. */ + addObjectStorageSource?(input: ConnectObjectStorageInput): 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..baf7a43dc 100644 --- a/module/services/frontend/code/packages/saas-ui/src/index.ts +++ b/module/services/frontend/code/packages/saas-ui/src/index.ts @@ -14,6 +14,7 @@ export { CollectionReadBoundary, } from "./datasources/collection-access.js"; export { ConnectGitHubForm } from "./datasources/connect-github-form.js"; +export { ConnectObjectStorageForm } from "./datasources/connect-object-storage-form.js"; export { DatasourcesPanel, type DatasourcesPanelProps, @@ -57,6 +58,8 @@ export { export { useAccessibleScopes, useAddGitHubSource, + useAddObjectStorageSource, + useDatasourceCatalog, useDeleteSource, useListSources, useReconcileSource, @@ -76,7 +79,9 @@ export { } from "./datasources/reconcile-passes.js"; export { type ConnectGitHubValues, + type ConnectObjectStorageValues, connectGitHubSchema, + connectObjectStorageSchema, type EditSourceValues, editSourceSchema, } from "./datasources/schema.js"; @@ -116,10 +121,12 @@ export type { CollectionGrantSubject, CollectionGrantView, ConnectGitHubInput, + ConnectObjectStorageInput, ConnectPersonalApiInput, DatasourceClient, DatasourceDirectoryView, DatasourceProviderName, + DatasourceProviderView, DatasourceStatusName, DatasourceView, DomainView, diff --git a/module/services/frontend/code/packages/saas-ui/stories/datasources.stories.tsx b/module/services/frontend/code/packages/saas-ui/stories/datasources.stories.tsx index 4a0ccd452..bc172e9c5 100644 --- a/module/services/frontend/code/packages/saas-ui/stories/datasources.stories.tsx +++ b/module/services/frontend/code/packages/saas-ui/stories/datasources.stories.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { ApiSourceCard, ConnectGitHubForm, + ConnectObjectStorageForm, type DatasourceClient, DatasourcesPanel, type DatasourceView, @@ -96,6 +97,26 @@ export const ConnectionValidation = { ); }, }; +/** + * Connecting a bucket, offered only once the host's catalog admits object + * storage. It shares the GitHub form's collection field, and says what a bucket + * cannot: every object is readable by whoever reads the collection. + */ +export const ConnectObjectStorage = { + render: function ConnectObjectStorageStory() { + const [message, setMessage] = useState(); + return ( + setMessage("Close this story to leave the preview.")} + onSubmit={() => + setMessage("Connection is unavailable in this preview.") + } + /> + ); + }, +}; /** * Editing a connected source. The fields arrive holding what the source already * fetches, and the repository and collection are stated rather than offered: diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json index 64291390b..8b84dde43 100644 --- a/module/services/frontend/code/ui-inventory.json +++ b/module/services/frontend/code/ui-inventory.json @@ -1535,12 +1535,37 @@ "coverage": "missing", "remaining": "Add owner stories for the grant table, the grant/revoke controls and the read boundary's denied state, then verify under external skins." }, + { + "source": "packages/saas-ui/src/datasources/collection-field.tsx", + "imports": [ + "react-hook-form", + "./credential-mode.js", + "@codefly-dev/ui/layout", + "./types.js" + ], + "owner": "@codefly-dev/saas-ui", + "disposition": "already canonical", + "reason": "Owner package implementation.", + "stories": [ + { + "file": "packages/saas-ui/stories/datasources.stories.tsx", + "export": "ConnectionValidation" + }, + { + "file": "packages/saas-ui/stories/datasources.stories.tsx", + "export": "ConnectObjectStorage" + } + ], + "coverage": "owner stories; browser/skin verification pending", + "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins." + }, { "source": "packages/saas-ui/src/datasources/connect-github-form.tsx", "imports": [ "@hookform/resolvers/zod", "react", "react-hook-form", + "./collection-field.js", "./schema.js", "@codefly-dev/ui/layout", "./types.js" @@ -1561,6 +1586,30 @@ "coverage": "owner stories; browser/skin verification pending", "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins." }, + { + "source": "packages/saas-ui/src/datasources/connect-object-storage-form.tsx", + "imports": [ + "@hookform/resolvers/zod", + "react", + "react-hook-form", + "./collection-field.js", + "./credential-mode.js", + "./schema.js", + "@codefly-dev/ui/layout", + "./types.js" + ], + "owner": "@codefly-dev/saas-ui", + "disposition": "already canonical", + "reason": "Owner package implementation.", + "stories": [ + { + "file": "packages/saas-ui/stories/datasources.stories.tsx", + "export": "ConnectObjectStorage" + } + ], + "coverage": "owner story; browser/skin verification pending", + "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins." + }, { "source": "packages/saas-ui/src/datasources/credential-mode.tsx", "imports": [ From 48ae3440d96a6630af96dff7ff77aa61807d6b07 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:36:09 -0400 Subject: [PATCH 2/3] refactor(saas-ui): the object-storage connect form leaves the catalog change for the key-pattern one The catalog change keeps what it is for: the panel reads GetDatasourceCatalog, offers what it admits and names the rest with the host's reason. The object-storage connect form, its shared collection field and the upload connect call move to the change that gives a bucket source its key filter, which the form now carries; the panel mounts it once the host admits the provider. Nothing here offered it before, because the host refuses the provider today. Refs #1211 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/no-raw-type-primitives.test.ts | 2 +- .../frontend/code/packages/saas-ui/README.md | 11 +- .../__tests__/datasources-panel.test.tsx | 71 ------- .../datasources/__tests__/gateway.test.tsx | 11 -- .../src/datasources/collection-field.tsx | 112 ----------- .../src/datasources/connect-github-form.tsx | 95 ++++++++-- .../connect-object-storage-form.tsx | 177 ------------------ .../src/datasources/datasources-panel.tsx | 84 +-------- .../saas-ui/src/datasources/gateway.ts | 22 --- .../saas-ui/src/datasources/queries.ts | 16 -- .../saas-ui/src/datasources/schema.ts | 27 --- .../packages/saas-ui/src/datasources/types.ts | 18 -- .../code/packages/saas-ui/src/index.ts | 5 - .../saas-ui/stories/datasources.stories.tsx | 21 --- .../services/frontend/code/ui-inventory.json | 49 ----- 15 files changed, 90 insertions(+), 631 deletions(-) delete mode 100644 module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx delete mode 100644 module/services/frontend/code/packages/saas-ui/src/datasources/connect-object-storage-form.tsx diff --git a/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts b/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts index 790d6254b..5d4c3d36b 100644 --- a/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts +++ b/module/services/frontend/code/packages/codefly-ui/src/__tests__/no-raw-type-primitives.test.ts @@ -78,7 +78,7 @@ const SAAS_UI_BASELINE: Record = { // Lowered from 16 when the credential block moved into // `datasources/credential-mode.tsx`, which carries none: the shared copy // names its slots, so the debt left behind shrank rather than moving. - "datasources/connect-github-form.tsx": 10, + "datasources/connect-github-form.tsx": 11, "datasources/datasources-panel.tsx": 15, }; const saasUiDir = resolve(srcDir, "../../saas-ui/src"); diff --git a/module/services/frontend/code/packages/saas-ui/README.md b/module/services/frontend/code/packages/saas-ui/README.md index f9b37be1b..ecd5ec0ee 100644 --- a/module/services/frontend/code/packages/saas-ui/README.md +++ b/module/services/frontend/code/packages/saas-ui/README.md @@ -270,19 +270,14 @@ const directory = usePrincipalDirectory(); // loading | ready | refused | failed 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 — **Connect - GitHub**, and **Connect object storage** when the client also implements - `addObjectStorageSource` — and every provider that takes no new source is + 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. -- `` — endpoint (https only), region, - bucket, prefix, access key id, the secret access key the host encrypts at - receipt, and the target collection. It says what a bucket cannot: every object - is readable by whoever reads the collection, so one prefix per audience. - `` — 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 @@ -401,7 +396,7 @@ 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`, - `useAddObjectStorageSource`, `useDatasourceCatalog`, `useUpdateSource`, + `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 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 9b8cff61e..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 @@ -1775,7 +1775,6 @@ describe("DatasourcesPanel connector catalog", () => { 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]), - addObjectStorageSource: vi.fn(async () => {}), }); renderWithClient(); @@ -1790,9 +1789,6 @@ describe("DatasourcesPanel connector catalog", () => { "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(); - expect( - screen.queryByRole("button", { name: "Connect object storage" }), - ).toBeNull(); }); it("withholds GitHub when the catalog does not admit it", async () => { @@ -1806,71 +1802,4 @@ describe("DatasourcesPanel connector catalog", () => { await screen.findByText(/GitHub takes no new sources to sync yet/); expect(screen.queryByRole("button", { name: "Connect GitHub" })).toBeNull(); }); - - it("connects a bucket the catalog admits, with its key as the credential", async () => { - const addObjectStorageSource = vi.fn(async () => {}); - const client = fakeClient({ - getCatalog: vi.fn(async () => [ - github, - { ...objectStorage, acceptsNewSources: true, gap: "" }, - ]), - addObjectStorageSource, - }); - renderWithClient(); - - fireEvent.click( - await screen.findByRole("button", { name: "Connect object storage" }), - ); - const fill = (label: string, value: string) => - fireEvent.change(screen.getByLabelText(label), { target: { value } }); - fill("Endpoint", "https://store.example.com"); - fill("Region", "us-east-1"); - fill("Bucket", "acme-docs"); - fill("Prefix (optional)", "handbook/"); - fill("Access key ID", "AKIAEXAMPLE"); - fill("Secret access key", "example-secret"); - fill("Target collection", "Handbook"); - fireEvent.click(screen.getByRole("button", { name: "Connect bucket" })); - - await waitFor(() => - expect(addObjectStorageSource).toHaveBeenCalledWith({ - orgId: "org-1", - endpoint: "https://store.example.com", - region: "us-east-1", - bucket: "acme-docs", - prefix: "handbook/", - accessKeyId: "AKIAEXAMPLE", - secretAccessKey: "example-secret", - targetCollection: "Handbook", - boundaryNodeId: undefined, - }), - ); - await waitFor(() => - expect( - screen.queryByRole("dialog", { name: "Connect object storage" }), - ).toBeNull(), - ); - }); - - it("refuses a store that is not https before asking the host", async () => { - const addObjectStorageSource = vi.fn(async () => {}); - const client = fakeClient({ - getCatalog: vi.fn(async () => [ - { ...objectStorage, acceptsNewSources: true, gap: "" }, - ]), - addObjectStorageSource, - }); - renderWithClient(); - - fireEvent.click( - await screen.findByRole("button", { name: "Connect object storage" }), - ); - fireEvent.change(screen.getByLabelText("Endpoint"), { - target: { value: "http://store.example.com" }, - }); - fireEvent.click(screen.getByRole("button", { name: "Connect bucket" })); - - expect(await screen.findByText("Use the store's https URL")).toBeTruthy(); - expect(addObjectStorageSource).not.toHaveBeenCalled(); - }); }); 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 c64f866a6..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 @@ -189,17 +189,6 @@ describe("createDatasourceClient", () => { }), deleteSource: () => client.deleteSource("org-1", "ds-1"), getCatalog: () => client.getCatalog!(), - addObjectStorageSource: () => - client.addObjectStorageSource!({ - orgId: "org-1", - endpoint: "https://store.example.com", - region: "us-east-1", - bucket: "acme-docs", - prefix: "kb/", - accessKeyId: "AKIAEXAMPLE", - secretAccessKey: "secret", - targetCollection: "Example", - }), // The stub answers {} to everything; an operation whose answer must // carry a record rejects on it, after the call this test counts. beginAccountLink: () => diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx deleted file mode 100644 index ca8a2f39b..000000000 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/collection-field.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { Input, Label } from "@codefly-dev/ui/layout"; -import type { UseFormRegisterReturn } from "react-hook-form"; -import { fieldErrorClass as errorClass } from "./credential-mode.js"; -import type { CollectionAccessView } from "./types.js"; - -interface CollectionFieldProps { - idFor: (name: string) => string; - collections?: CollectionAccessView[]; - readableNodeIds?: string[]; - collectionError?: boolean; - boundaryNodeId?: string; - targetCollection?: string; - /** An existing collection was picked, or "" to create one. */ - onSelect: (nodeId: string, label: string) => void; - inputProps: UseFormRegisterReturn; - error?: string; -} - -/** - * Where a new source's content lands: an existing collection, or a label to - * create one under. Every connect form shares it, so who can read the result - * is told the same way whichever provider is being connected. - */ -export function CollectionField({ - idFor, - collections, - readableNodeIds, - collectionError, - boundaryNodeId, - targetCollection, - onSelect, - inputProps, - error, -}: CollectionFieldProps) { - const selected = collections?.find( - (collection) => collection.nodeId === boundaryNodeId, - ); - const named = - selected ?? - collections?.find((collection) => collection.label === targetCollection); - return ( -

- {collections && ( - <> - - - - )} - - -

- Connecting grants no read access to the creator or a default team. - Organization administrators explicitly grant members or teams read - access. Platform administrators read every collection without a grant. -

- {collectionError ? ( -

Couldn’t inspect collection grants.

- ) : named ? ( -

- Readers:{" "} - {named.grants.map((grant) => grant.subjectLabel).join(", ") || - "No collection read grants"} - .{" "} - {readableNodeIds === undefined - ? "Your read permission is unresolved." - : readableNodeIds.includes(named.nodeId) - ? "You can read this collection." - : "You do not have read access. Ask an administrator for access."} -

- ) : ( -

- {collections - ? "New collections have no read grants. Your read permission must be checked after creation." - : "Collection grants are unresolved. Inspect them in the host after connecting."} -

- )} - {error && ( -

- {error} -

- )} -
- ); -} 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 8d9cdc20b..88f35524c 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 @@ -4,7 +4,6 @@ import { Button, Input, Label, Textarea } from "@codefly-dev/ui/layout"; import { zodResolver } from "@hookform/resolvers/zod"; import { useId } from "react"; import { useForm, useWatch } from "react-hook-form"; -import { CollectionField } from "./collection-field.js"; import { AccessTokenField, AppInstallPrompt, @@ -79,6 +78,12 @@ export function ConnectGitHubForm({ control: form.control, name: "targetCollection", }); + const selected = collections?.find( + (collection) => collection.nodeId === boundaryNodeId, + ); + const named = + selected ?? + collections?.find((collection) => collection.label === targetCollection); return (
- { - form.setValue("boundaryNodeId", nodeId); - form.setValue("targetCollection", label); - }} - inputProps={form.register("targetCollection")} - {...(errors.targetCollection?.message - ? { error: errors.targetCollection.message } - : {})} - /> +
+ {collections && ( + <> + + + + )} + + +

+ Connecting grants no read access to the creator or a default team. + Organization administrators explicitly grant members or teams read + access. Platform administrators read every collection without a + grant. +

+ {collectionError ? ( +

Couldn’t inspect collection grants.

+ ) : named ? ( +

+ Readers:{" "} + {named.grants.map((grant) => grant.subjectLabel).join(", ") || + "No collection read grants"} + .{" "} + {readableNodeIds === undefined + ? "Your read permission is unresolved." + : readableNodeIds.includes(named.nodeId) + ? "You can read this collection." + : "You do not have read access. Ask an administrator for access."} +

+ ) : ( +

+ {collections + ? "New collections have no read grants. Your read permission must be checked after creation." + : "Collection grants are unresolved. Inspect them in the host after connecting."} +

+ )} + {errors.targetCollection && ( +

+ {errors.targetCollection.message} +

+ )} +
{method === "pat" && ( void; - onCancel: () => void; - isPending: boolean; - errorMessage?: string; -} - -const textFields = [ - { - name: "endpoint", - label: "Endpoint", - placeholder: "https://s3.us-east-1.amazonaws.com", - }, - { name: "region", label: "Region", placeholder: "us-east-1" }, - { name: "bucket", label: "Bucket", placeholder: "acme-knowledge" }, - { name: "prefix", label: "Prefix (optional)", placeholder: "handbook/" }, - { name: "accessKeyId", label: "Access key ID", placeholder: "" }, -] as const; - -export function ConnectObjectStorageForm({ - collections, - readableNodeIds, - collectionError, - onSubmit, - onCancel, - isPending, - errorMessage, -}: ConnectObjectStorageFormProps) { - const fieldId = useId(); - const idFor = (name: string) => `${fieldId}-${name}`; - const form = useForm({ - resolver: zodResolver(connectObjectStorageSchema), - defaultValues: { - endpoint: "", - region: "", - bucket: "", - prefix: "", - accessKeyId: "", - secretAccessKey: "", - boundaryNodeId: "", - targetCollection: "", - }, - }); - const { errors } = form.formState; - const boundaryNodeId = useWatch({ - control: form.control, - name: "boundaryNodeId", - }); - const targetCollection = useWatch({ - control: form.control, - name: "targetCollection", - }); - - return ( -
-
-
-

- Connect object storage -

-

- Register a bucket as a data source. The objects under its prefix are - pulled on sync, and only what changed is sent again. Everyone who - may read the collection reads every object: a bucket has no - per-object access list, so use one prefix per audience. -

-
- -
- {textFields.map((field) => { - const error = errors[field.name]?.message; - return ( -
- - - {error && ( -

- {error} -

- )} -
- ); - })} - -
- - -

- Encrypted when the host receives it and never shown again. Give - the key read and list access to this bucket only. -

- {errors.secretAccessKey && ( -

- {errors.secretAccessKey.message} -

- )} -
- - { - form.setValue("boundaryNodeId", nodeId); - form.setValue("targetCollection", label); - }} - inputProps={form.register("targetCollection")} - {...(errors.targetCollection?.message - ? { error: errors.targetCollection.message } - : {})} - /> - - {errorMessage && ( -

- {errorMessage} -

- )} - -
- - -
- -
-
- ); -} diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx b/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx index c2f3b7e65..3255009ec 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/datasources-panel.tsx @@ -39,7 +39,6 @@ import { } from "../solution/viewer.js"; import { CollectionGrants } from "./collection-access.js"; import { ConnectGitHubForm } from "./connect-github-form.js"; -import { ConnectObjectStorageForm } from "./connect-object-storage-form.js"; import { EditSourceForm } from "./edit-source-form.js"; import { messageOf } from "./errors.js"; import { createDatasourceClient, type GatewayBinding } from "./gateway.js"; @@ -47,7 +46,6 @@ import { readAppSetupReturn, scrubAppSetupReturn } from "./github-app-setup.js"; import { useAccessibleScopes, useAddGitHubSource, - useAddObjectStorageSource, useDatasourceCatalog, useDeleteSource, useListSources, @@ -55,11 +53,7 @@ import { useUpdateSource, } from "./queries.js"; import type { AdmittedReconcilePass } from "./reconcile-passes.js"; -import type { - ConnectGitHubValues, - ConnectObjectStorageValues, - EditSourceValues, -} from "./schema.js"; +import type { ConnectGitHubValues, EditSourceValues } from "./schema.js"; import { SourceSyncProgress, useSourceSync } from "./sync-progress.js"; import type { AccessibleScopeView, @@ -334,13 +328,11 @@ function DatasourcesPanelView({ ) : undefined; const addMutation = useAddGitHubSource(client); - const addObjectStorageMutation = useAddObjectStorageSource(client); - const [connectingObjectStorage, setConnectingObjectStorage] = useState(false); // The catalog decides what is offered: a provider that accepts new sources // gets its connect form, if this kit has one, and every other provider is - // named with the host's reason it takes none. A client that does not read the - // catalog — or whose read failed — offers GitHub alone, as before the catalog - // was read; the host refuses a provider it does not admit either way. + // named with the host's reason it takes none. A client that does not read + // the catalog — or whose read failed — offers GitHub alone, as before the + // catalog was read; the host refuses a provider it does not admit either way. const catalog = useDatasourceCatalog(client); const accepts = (connector: string) => !!catalog.data?.some( @@ -348,37 +340,9 @@ function DatasourcesPanelView({ provider.connector === connector && provider.acceptsNewSources, ); const offerGitHub = catalog.isSuccess ? accepts("github") : true; - const offerObjectStorage = - catalog.isSuccess && accepts("upload") && !!client.addObjectStorageSource; const refusedProviders = catalog.isSuccess ? catalog.data.filter((provider) => !provider.acceptsNewSources) : []; - - const handleConnectObjectStorage = (values: ConnectObjectStorageValues) => { - addObjectStorageMutation.mutate( - { - orgId, - endpoint: values.endpoint, - region: values.region, - bucket: values.bucket, - prefix: values.prefix, - accessKeyId: values.accessKeyId, - secretAccessKey: values.secretAccessKey, - targetCollection: values.targetCollection, - boundaryNodeId: values.boundaryNodeId || undefined, - }, - { - onSuccess: () => { - setConnectingObjectStorage(false); - setJustConnected({ - repo: `${values.bucket}/${values.prefix}`, - nodeId: values.boundaryNodeId || undefined, - label: values.targetCollection, - }); - }, - }, - ); - }; const reconcileMutation = useReconcileSource(client); const deleteMutation = useDeleteSource(client); const updateMutation = useUpdateSource(client); @@ -651,23 +615,10 @@ function DatasourcesPanelView({ Repositories this organization ingests from.

- {canManage && (offerGitHub || offerObjectStorage) && ( -
- {offerGitHub && ( - - )} - {offerObjectStorage && ( - - )} -
+ {canManage && offerGitHub && ( + )} {canManage && refusedProviders.length > 0 && ( @@ -1017,25 +968,6 @@ function DatasourcesPanelView({ } /> )} - {canManage && connectingObjectStorage && ( - setConnectingObjectStorage(false)} - isPending={addObjectStorageMutation.isPending} - errorMessage={ - addObjectStorageMutation.isError - ? messageOf(addObjectStorageMutation.error) - : undefined - } - /> - )} {canManage && showConnect && ( - client.addObjectStorageSource!(input), - onSuccess: (_result, input) => { - queryClient.invalidateQueries({ queryKey: sourcesKey(input.orgId) }); - queryClient.invalidateQueries({ - queryKey: ["collection-access", input.orgId], - }); - queryClient.invalidateQueries({ queryKey: scopesKey(input.orgId) }); - }, - }); -} - export function useAddGitHubSource(client: DatasourceClient) { const queryClient = useQueryClient(); return useMutation({ diff --git a/module/services/frontend/code/packages/saas-ui/src/datasources/schema.ts b/module/services/frontend/code/packages/saas-ui/src/datasources/schema.ts index e3084a8e2..ef685af9a 100644 --- a/module/services/frontend/code/packages/saas-ui/src/datasources/schema.ts +++ b/module/services/frontend/code/packages/saas-ui/src/datasources/schema.ts @@ -92,30 +92,3 @@ export const connectGitHubSchema = z }); export type ConnectGitHubValues = z.infer; - -export const connectObjectStorageSchema = z.object({ - endpoint: z - .string() - .max(2048, "Endpoint too long") - .regex(/^https:\/\/[^\s/]+/, "Use the store's https URL"), - region: z.string().min(1, "Region is required").max(64, "Region too long"), - bucket: z.string().min(1, "Bucket is required").max(255, "Bucket too long"), - prefix: z.string().max(1024, "Prefix too long"), - accessKeyId: z - .string() - .min(1, "Access key ID is required") - .max(256, "Access key ID too long"), - secretAccessKey: z - .string() - .min(1, "Secret access key is required") - .max(4096, "Secret access key too long"), - boundaryNodeId: z.string().optional(), - targetCollection: z - .string() - .min(1, "Target collection is required") - .max(255, "Target collection too long"), -}); - -export type ConnectObjectStorageValues = z.infer< - typeof connectObjectStorageSchema ->; 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 124ab9632..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,22 +202,6 @@ export interface ConnectGitHubInput { webhookSecret: string; } -/** - * An object-storage source, ready for `addObjectStorageSource`: a bucket and a - * key prefix in a store, and the access key the host encrypts at receipt. - */ -export interface ConnectObjectStorageInput { - orgId: string; - endpoint: string; - region: string; - bucket: string; - prefix: string; - accessKeyId: string; - secretAccessKey: string; - targetCollection: string; - boundaryNodeId?: string; -} - /** * One provider of the host's connector catalog (`GetDatasourceCatalog`). A * surface offers a provider whose `acceptsNewSources` is true and flags the @@ -460,8 +444,6 @@ export interface DatasourceClient { * GitHub alone, as it did before the catalog was read. */ getCatalog?(): Promise; - /** Connects an object-storage source. Optional, like the catalog. */ - addObjectStorageSource?(input: ConnectObjectStorageInput): 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 baf7a43dc..338d40d46 100644 --- a/module/services/frontend/code/packages/saas-ui/src/index.ts +++ b/module/services/frontend/code/packages/saas-ui/src/index.ts @@ -14,7 +14,6 @@ export { CollectionReadBoundary, } from "./datasources/collection-access.js"; export { ConnectGitHubForm } from "./datasources/connect-github-form.js"; -export { ConnectObjectStorageForm } from "./datasources/connect-object-storage-form.js"; export { DatasourcesPanel, type DatasourcesPanelProps, @@ -58,7 +57,6 @@ export { export { useAccessibleScopes, useAddGitHubSource, - useAddObjectStorageSource, useDatasourceCatalog, useDeleteSource, useListSources, @@ -79,9 +77,7 @@ export { } from "./datasources/reconcile-passes.js"; export { type ConnectGitHubValues, - type ConnectObjectStorageValues, connectGitHubSchema, - connectObjectStorageSchema, type EditSourceValues, editSourceSchema, } from "./datasources/schema.js"; @@ -121,7 +117,6 @@ export type { CollectionGrantSubject, CollectionGrantView, ConnectGitHubInput, - ConnectObjectStorageInput, ConnectPersonalApiInput, DatasourceClient, DatasourceDirectoryView, diff --git a/module/services/frontend/code/packages/saas-ui/stories/datasources.stories.tsx b/module/services/frontend/code/packages/saas-ui/stories/datasources.stories.tsx index bc172e9c5..4a0ccd452 100644 --- a/module/services/frontend/code/packages/saas-ui/stories/datasources.stories.tsx +++ b/module/services/frontend/code/packages/saas-ui/stories/datasources.stories.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { ApiSourceCard, ConnectGitHubForm, - ConnectObjectStorageForm, type DatasourceClient, DatasourcesPanel, type DatasourceView, @@ -97,26 +96,6 @@ export const ConnectionValidation = { ); }, }; -/** - * Connecting a bucket, offered only once the host's catalog admits object - * storage. It shares the GitHub form's collection field, and says what a bucket - * cannot: every object is readable by whoever reads the collection. - */ -export const ConnectObjectStorage = { - render: function ConnectObjectStorageStory() { - const [message, setMessage] = useState(); - return ( - setMessage("Close this story to leave the preview.")} - onSubmit={() => - setMessage("Connection is unavailable in this preview.") - } - /> - ); - }, -}; /** * Editing a connected source. The fields arrive holding what the source already * fetches, and the repository and collection are stated rather than offered: diff --git a/module/services/frontend/code/ui-inventory.json b/module/services/frontend/code/ui-inventory.json index 8b84dde43..64291390b 100644 --- a/module/services/frontend/code/ui-inventory.json +++ b/module/services/frontend/code/ui-inventory.json @@ -1535,37 +1535,12 @@ "coverage": "missing", "remaining": "Add owner stories for the grant table, the grant/revoke controls and the read boundary's denied state, then verify under external skins." }, - { - "source": "packages/saas-ui/src/datasources/collection-field.tsx", - "imports": [ - "react-hook-form", - "./credential-mode.js", - "@codefly-dev/ui/layout", - "./types.js" - ], - "owner": "@codefly-dev/saas-ui", - "disposition": "already canonical", - "reason": "Owner package implementation.", - "stories": [ - { - "file": "packages/saas-ui/stories/datasources.stories.tsx", - "export": "ConnectionValidation" - }, - { - "file": "packages/saas-ui/stories/datasources.stories.tsx", - "export": "ConnectObjectStorage" - } - ], - "coverage": "owner stories; browser/skin verification pending", - "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins." - }, { "source": "packages/saas-ui/src/datasources/connect-github-form.tsx", "imports": [ "@hookform/resolvers/zod", "react", "react-hook-form", - "./collection-field.js", "./schema.js", "@codefly-dev/ui/layout", "./types.js" @@ -1586,30 +1561,6 @@ "coverage": "owner stories; browser/skin verification pending", "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins." }, - { - "source": "packages/saas-ui/src/datasources/connect-object-storage-form.tsx", - "imports": [ - "@hookform/resolvers/zod", - "react", - "react-hook-form", - "./collection-field.js", - "./credential-mode.js", - "./schema.js", - "@codefly-dev/ui/layout", - "./types.js" - ], - "owner": "@codefly-dev/saas-ui", - "disposition": "already canonical", - "reason": "Owner package implementation.", - "stories": [ - { - "file": "packages/saas-ui/stories/datasources.stories.tsx", - "export": "ConnectObjectStorage" - } - ], - "coverage": "owner story; browser/skin verification pending", - "remaining": "Verify explorer rendering, interactive accessibility and visual baselines under external skins." - }, { "source": "packages/saas-ui/src/datasources/credential-mode.tsx", "imports": [ From 0e1bf4efa5452f4257a3b966f5b7323833fb9a84 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:55:49 -0400 Subject: [PATCH 3/3] build(saas-ui): the kit moves to 0.12.16, past the 0.12.15 that v0.0.105 published The catalog read changes saas-ui's published bytes, and v0.0.105 already released 0.12.15, so the kit-version gate needs a new version. A patch, because the change is additive; @codefly-dev/ui moves with it (the kit is co-versioned), and the workspace lockfile is regenerated by npm, not edited. Refs #1211 Co-Authored-By: Claude Opus 5.5 (1M context) --- module/services/frontend/code/package-lock.json | 4 ++-- .../services/frontend/code/packages/codefly-ui/package.json | 2 +- module/services/frontend/code/packages/saas-ui/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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/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",