diff --git a/.claude/skills/openbot-screen-layout/SKILL.md b/.claude/skills/openbot-screen-layout/SKILL.md index a8e5f96c..04ebc41c 100644 --- a/.claude/skills/openbot-screen-layout/SKILL.md +++ b/.claude/skills/openbot-screen-layout/SKILL.md @@ -71,8 +71,9 @@ here yet" is a fact, and the section heading already said what the section is fo 4. Give each section one `PageRows` card. Rows go inside it as `Item size="sm"`, with `` between them and none after the last. `PageRows` is a card with dividers, not a stack of cards — gaps between rows are the wrong shape. -5. Read `admin/connectors.tsx` for the whole pattern end to end, and - `admin/components/$name.tsx` for a screen with two sections and mixed row kinds. +5. Read `admin/plugins/index.tsx` for the whole pattern end to end, and + `admin/plugins/$key.tsx` or `admin/components/$name.tsx` for a screen with several sections and + mixed row kinds. ### Procedure 2: Compose a row @@ -205,7 +206,7 @@ to sit a centred element visibly off centre or clip a card's corners against its - **A row's summary does not change after its dialog edits something**: the summary was captured into state instead of computed from the query. Derive it on every render. - **Two screens that should match do not**: one of them drew its own container. Diff the two against - `admin/connectors.tsx` and delete whichever hand-drawn wrapper is not `PageRows`. + `admin/plugins/index.tsx` and delete whichever hand-drawn wrapper is not `PageRows`. - **The layout genuinely cannot express the screen**: stop and say so rather than bending it silently. A deviation with a stated reason and a comment is fine; an undocumented fifth way to draw a card is what this skill exists to prevent. diff --git a/.env.example b/.env.example index 126017d3..38417a98 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,19 @@ OPENBOT_SINGLE_USER=true # OKTA_OAUTH_CLIENT_SECRET= # OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default +# Where this deployment is reached from outside. Only needed for connectors that a person connects +# their own account to, such as Google Drive: it builds the redirect URI the vendor sends them back +# to, which has to match what an administrator registered character for character. Defaults to +# BETTER_AUTH_URL, so most deployments never set it; set it where the API is behind a different +# public address than the one sign-in uses. Without either, the Plugins page says so and nobody can +# connect an account. +# OPENBOT_PUBLIC_URL=https://openbot.example.com + +# Where the browser app is served from. The OAuth callback lands on the API and has to send the +# person back to a page, and locally those are two different ports: the app is 3010 and the API is +# 3001. Defaults to the first TRUSTED_ORIGINS entry, then to OPENBOT_PUBLIC_URL, which is right for a +# deployment serving both from one origin. +# OPENBOT_APP_URL=http://localhost:3010 TRUSTED_ORIGINS=http://localhost:3010 # CopilotKit Intelligence. Required: the server refuses to start without all four because diff --git a/CHANGELOG.md b/CHANGELOG.md index e8bc3681..387f75a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,25 @@ digit. The same rule container and volume names have always followed. A deployme `AUDIT_RETENTION_DAYS` is new and unset, which keeps the audit trail forever, as before. Set it to a whole number of days to have old rows removed. +**An MCP server pointed at a credential that no longer exists loses the pointer.** `mcp_servers` +now names its credential with a real foreign key, where the column was `text` against a `uuid` +primary key with nothing checking it — so a deployment is allowed to be holding a pointer to a vault +row that was deleted underneath it, and the screens read as though the server were still configured. +The migration clears those before adding the key, because it cannot add it otherwise. If this +happens, that connector correctly reports having no credential and an administrator registers it +again; nothing else is affected, and a deployment with no such pointer sees nothing. + +**The old Google Drive connector is gone, and it is not the new one renamed.** It configured a +service account with domain impersonation and had the worker sync documents into a local pgvector +index guarded by our own ACL rows, so every person got the same answer computed from what one +credential could see, and revoking somebody's access left a cached copy of their documents behind. +`/admin/connectors` and its two screens, the connector catalogue and admin service, the sync +persistence and the worker's connector runner have all been removed. A deployment that was syncing +this way stops syncing and should enable the new connector at `/admin/plugins/google-drive`, where +each person connects their own account. + +`knowledge.yaml` is still parsed and still refused when malformed, because it is part of the +deployment-package contract. Its `sources:` are now read by nothing. `MANAGED_AGENT_AG_UI_URL` is no longer required to start. The one-container image does not carry a Bot, so requiring it registered the shipped Risk Analyst against a host that was not there and every conversation with it failed. Leave it unset for that image. A laptop `scripts/start.sh` still points @@ -63,6 +82,46 @@ Sessions survive and nobody signs in again. and skips routing entirely. If the router is uncertain or unreachable, it falls back to the same default the composer always used, and says so, rather than misroute or drop. +- **A Bot can answer from Google Drive, as the person asking.** Ask a Bot a question whose answer is + in a document and it answers from the live file rather than from an index, citing a link that opens + it. A Bot granted these tools reads Drive on the asker's own grant, so two people asking the same + question get the answers their own accounts can see, and neither sees the other's documents. + Read-only: the scope requested is `drive.readonly`, so a write is refused by Google before this + deployment has to. Nothing is cached — the refresh token is stored and an access token is minted + per call, so revoking access at Google takes effect on the next one rather than when a cache + expires. + + Setting it up takes two people and neither can do the other's half. An administrator registers a + Google Cloud OAuth client and enables the connector at `/admin/plugins/google-drive`; each person + then connects their own account, and there is deliberately no endpoint for an administrator to + connect one on somebody's behalf. The redirect URI has to match what is registered character for + character, and the connector page states the exact string to paste, because a mismatch fails at + Google with a message that never mentions OpenBot. See + [docs/plugins/google-drive.md](docs/plugins/google-drive.md) for the whole setup and for what each + failure means. + + **Disconnecting is not built yet.** The account page says so and points at Google's own third-party + access settings, which is what withdraws it today. +- **Each tool a connector offers has its own screen**, at `/admin/plugins//tools/`, + with a switch per Bot. The connector page previously drew a button per Bot inside every tool row, + which is a control per Bot per tool stacked in one list, and grew without bound as Bots were added. +- **Connected accounts**, at `/settings/connected-accounts`. What a Bot may read as you, and the + scope the vendor actually granted rather than the one that was asked for. +- **A tool result that found nothing says so.** An empty result used to reach the model as an empty + string, which reads as "the tool had nothing to say" rather than "there is nothing there" — and a + model closes that gap from memory, which for a knowledge connector is the failure worth preventing. +- **The shipped Knowledge Bot answers from the tools it has.** Its instructions in + `examples/fintech` told it to say no source was connected, which was honest when none could be: + the connector this replaces had been removed and nothing had taken its place. With a connector + granted it became the opposite of honest — the Bot called a tool, was handed a file listing, and + said it had no access anyway. It now reports what its tools return, says so plainly when it has no + tool or a tool reports a problem, and does neither of the two things worth forbidding: answering + from its own memory as though it came from a source, or claiming to lack access to something a tool + has just returned. A deployment with its own tenant package is unaffected. +- **`mcp.call_failed`.** A call this deployment permitted and the vendor did not complete now leaves + a row of its own, carrying the vendor's own sentence. `mcp.call_succeeded` was written before the + network call rather than after, so a call that died at the vendor recorded success and the Admin + page agreed with it. - **Releases are cut by a workflow, not by hand.** `Create release PR` bumps the version and promotes `## Unreleased` to a numbered section; merging the pull request it opens is what publishes. Merging builds and pushes one image to `ghcr.io/copilotkit/openbot`, signs a build provenance attestation @@ -125,6 +184,16 @@ Sessions survive and nobody signs in again. is unavailable never blocks a sign-in. ### Fixed +- **Removing somebody left the credentials they had granted this deployment sitting in the vault.** + Removing them from the People screen ended their sessions and stopped the next sign-in, and left the + refresh token behind, unrevoked. They could not use it — the account comes from a session they no + longer get — but "we removed their access" was not true of the token, which for a connector read as + the person asking is the part that matters. Removing somebody now retires it, and each retirement is + on the audit trail as `mcp.account_disconnected`. Deleting a person's row used to be worse, because + it took the connection record with it and left the credential reachable by nothing at all; those are + found and retired too. This stops the deployment holding a usable secret. It does **not** withdraw + the grant at the vendor, which needs revoking there until disconnect ships, and the audit row says + which of the two happened rather than implying both. - **The one-container image registered a coworker it could not run.** `MANAGED_AGENT_AG_UI_URL` defaulted to `localhost:4201` and was required, so Risk Analyst appeared on the roster and every conversation with it failed. The URL is optional; the package omits that coworker when it is diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx index 6bda3847..47aa5b46 100644 --- a/app/src/components/admin/admin-sidebar.tsx +++ b/app/src/components/admin/admin-sidebar.tsx @@ -3,10 +3,10 @@ import { IconBuildingBank, IconCode, IconDeviceDesktop, + IconFileText, IconKey, IconLayoutGrid, IconListDetails, - IconPlugConnected, IconPuzzle, IconShieldCheck, IconUsers, @@ -29,11 +29,11 @@ const appLinkOptions = { to: "/" } satisfies LinkOptions; const adminLinkOptions = { to: "/admin" } satisfies LinkOptions; /** - * The same three groups, in the same order, as the admin index. + * The same four groups, in the same order, as the admin index. * - * A rail that lists eight things flat asks somebody to know which of them is the one they want. The + * A rail that lists ten things flat asks somebody to know which of them is the one they want. The * grouping is the only navigation help this screen offers, so it has to agree with the page it - * navigates to — two different orderings of the same eight links is worse than either ordering. + * navigates to — two different orderings of the same ten links is worse than either ordering. */ const GROUPS: { label: string; @@ -46,11 +46,6 @@ const GROUPS: { { label: "What Bots can reach", items: [ - { - title: "Connectors", - icon: IconPlugConnected, - linkOptions: { to: "/admin/connectors" }, - }, { title: "Credentials", icon: IconKey, @@ -76,6 +71,11 @@ const GROUPS: { icon: IconPuzzle, linkOptions: { to: "/admin/plugins" }, }, + { + title: "Skills", + icon: IconFileText, + linkOptions: { to: "/admin/skills" }, + }, { title: "UI Components", icon: IconLayoutGrid, diff --git a/app/src/components/layout/row-mark.tsx b/app/src/components/layout/row-mark.tsx new file mode 100644 index 00000000..b7fdbbab --- /dev/null +++ b/app/src/components/layout/row-mark.tsx @@ -0,0 +1,38 @@ +import type * as React from "react"; +import { ItemMedia } from "@/components/ui/item"; +import { cn } from "@/lib/utils"; + +/** + * A row's leading icon, as a tile rather than a bare glyph. + * + * A DELIBERATE DEVIATION from the default row anatomy, which is `ItemMedia variant="icon"` and + * nothing else. Stated here because the layout skill asks for a reason when a screen departs from it. + * + * FOR ONE LIST ONLY: the connectors on `admin/plugins`. Every row there is another company, and the + * tile carries that vendor's own mark — so it is doing work no other row in the app needs, which is + * telling third parties apart at a glance. `variant="icon"` puts a 15px glyph straight against the + * text, and a list of vendors read that way has no fixed left edge for the eye to run down. + * + * Not for a detail page, and not for skills. Those rows are this deployment's own settings and its + * own instructions; there is no third party to identify, so they take the standard media and the + * screens stay consistent with the other eleven that use it. + * + * One component rather than a class literal at every call site, because the whole value is that the + * tiles are identical: copies of `size-9 rounded-lg bg-muted/60` are chances for one of them to + * drift, and a list with one tile a pixel out looks broken rather than varied. + */ +export function RowMark({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} diff --git a/app/src/components/settings/settings-sidebar.tsx b/app/src/components/settings/settings-sidebar.tsx index 5a99b398..9eb1730b 100644 --- a/app/src/components/settings/settings-sidebar.tsx +++ b/app/src/components/settings/settings-sidebar.tsx @@ -1,6 +1,7 @@ import { IconArrowLeft, IconLayoutGrid, + IconPlug, IconSettings, } from "@tabler/icons-react"; import { Link, type LinkOptions } from "@tanstack/react-router"; @@ -37,6 +38,15 @@ const ITEMS: { exact: true, linkOptions: { to: "/settings" }, }, + { + /* + * The same subject as Admin's Plugins, from the other side: there an administrator decides what + * this deployment may reach at all, here you decide what it may reach as you. + */ + title: "Connected accounts", + icon: IconPlug, + linkOptions: { to: "/settings/connected-accounts" }, + }, { /* The same mark Admin gives UI Components. It is the same subject seen from the other side. */ title: "Components gallery", diff --git a/app/src/lib/connectors/mutations.ts b/app/src/lib/connectors/mutations.ts deleted file mode 100644 index 0c627f80..00000000 --- a/app/src/lib/connectors/mutations.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { mutationOptions, type QueryClient } from "@tanstack/react-query"; -import { client } from "@/lib/client"; -import { connectorKeys } from "./queries"; - -/** What Google Drive needs before it can read anything on a deployment's behalf. */ -export type GoogleDriveSetupInput = { - serviceAccountJson: string; - impersonationSubject: string; -}; - -/** - * Configure the Google Drive connector. - * - * The service account JSON is a credential, so it goes one way only: it is sent here and never read - * back. What a later read returns is whether the connector is configured, not what it was configured - * with. - */ -export function setUpGoogleDriveMutationOptions(queryClient: QueryClient) { - return mutationOptions({ - mutationFn: async (input: GoogleDriveSetupInput) => { - await client("/api/admin/connectors/google-drive/setup", { - method: "POST", - body: input, - fallback: "Could not set up Google Drive", - }); - }, - onSuccess: () => - queryClient.invalidateQueries({ queryKey: connectorKeys.all }), - }); -} diff --git a/app/src/lib/connectors/queries.ts b/app/src/lib/connectors/queries.ts deleted file mode 100644 index 6f3d984f..00000000 --- a/app/src/lib/connectors/queries.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { queryOptions } from "@tanstack/react-query"; -import { client } from "@/lib/client"; - -export type ConnectorStatus = { - id: string; - type: "google_drive" | "onedrive"; - name: string; - roots: string[]; - configured: boolean; -}; - -export const connectorKeys = { - all: ["connectors"] as const, - list: () => [...connectorKeys.all, "list"] as const, -}; - -export function connectorListQueryOptions() { - return queryOptions({ - queryKey: connectorKeys.list(), - queryFn: async (): Promise => { - return client("/api/admin/connectors", "connectors", { - fallback: "Could not load connectors", - }); - }, - }); -} diff --git a/app/src/lib/markdown.tsx b/app/src/lib/markdown.tsx index 0342968e..1dae79d0 100644 --- a/app/src/lib/markdown.tsx +++ b/app/src/lib/markdown.tsx @@ -1,3 +1,9 @@ +import { + IconFile, + IconFileText, + IconPresentation, + IconTable, +} from "@tabler/icons-react"; import type { ComponentProps } from "react"; /** @@ -6,16 +12,97 @@ import type { ComponentProps } from "react"; * Links open in a new tab with `noreferrer` because content can come from a model or remote MCP * server. */ + +/** + * A document this deployment can recognise, drawn as a chip rather than as underlined text. + * + * WHY A CHIP. A knowledge answer is mostly a claim plus the thing it came from, and those two want + * to look different. Underlined blue text in the middle of a sentence reads as "more about this"; + * a chip with the file's own type on it reads as "this is the document", which is the whole point of + * a connector that answers from a live system. It also survives the model's phrasing: whether it + * writes "I found it in X" or lists three files, each one is drawn the same way. + * + * Recognition is by URL, and only Google's own document hosts. Anything else is an ordinary link, + * because a chip asserts "this is a file in a system you have connected" and that is not something + * to claim about a URL a model wrote. + */ +const DRIVE_KINDS = [ + { match: "/document/", icon: IconFileText, label: "Doc" }, + { match: "/spreadsheets/", icon: IconTable, label: "Sheet" }, + { match: "/presentation/", icon: IconPresentation, label: "Slides" }, +] as const; + +function driveKind(href: string | undefined) { + if (!href) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + // A relative or malformed href is not a Drive document, and is not worth throwing over. + return null; + } + + /* + * Exact hosts, never a suffix test. `docs.google.com.evil.test` ends with the string and is + * somebody else's domain, and a chip is a statement that this is a real file in a real connected + * system — the one kind of link where dressing up an impostor does actual harm. + */ + if (url.protocol !== "https:") return null; + if (url.hostname === "docs.google.com") { + const kind = DRIVE_KINDS.find((entry) => + url.pathname.includes(entry.match), + ); + // A docs.google.com URL of some other shape is still a Drive document, just not one of the three. + return kind ?? { match: "", icon: IconFile, label: "Drive" }; + } + if (url.hostname === "drive.google.com") { + return { match: "", icon: IconFile, label: "Drive" }; + } + return null; +} + export const markdownComponents = { - a: ({ href, children, ...rest }: ComponentProps<"a">) => ( - - {children} - - ), + a: ({ href, children, ...rest }: ComponentProps<"a">) => { + const kind = driveKind(href); + + if (kind) { + const Icon = kind.icon; + return ( + + + {/* Truncated rather than wrapped: a long file name should not reflow the sentence around it. */} + {children} + {/* + * The type, after the name. It answers "can I open this, and with what" without the reader + * hovering to read a URL, and it is the part a file name often leaves out. + */} + {kind.label} + + ); + } + + return ( + + {children} + + ); + }, }; diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts index 126fb305..e2e2228c 100644 --- a/app/src/lib/plugins/mutations.ts +++ b/app/src/lib/plugins/mutations.ts @@ -158,6 +158,70 @@ export function saveSkillMutationOptions(queryClient: QueryClient) { }); } +/** The deployment's OAuth client for a vendor reached as the person asking. */ +export type OAuthClientInput = { + serverId: string; + clientId: string; + clientSecret: string; +}; + +/** + * Register the deployment's OAuth client for a `user-oauth` server. + * + * Its own write rather than a field on the curated-server input, because it has its own lifetime: a + * client is rotated without the server being re-added, and re-adding a server should not mean + * re-typing a client. It is also recorded against the server row, so it can only happen once that + * row exists — which is why the page chains it rather than sending both at once. + * + * Nobody's documents are reachable with what this sends. A client identifies this deployment to the + * vendor; the grant that reads anything belongs to each person and is made on their own settings page. + */ +export function registerOAuthClientMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (input: OAuthClientInput) => { + await client( + `/api/plugins/servers/${encodeURIComponent(input.serverId)}/oauth-client`, + { + method: "POST", + body: { clientId: input.clientId, clientSecret: input.clientSecret }, + fallback: "That OAuth client could not be registered.", + }, + ); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +/** + * Begin connecting the signed-in person's own account. + * + * Answers with the vendor's consent URL rather than navigating, so the caller decides when to leave + * the page. There is deliberately nothing here that could complete the consent on somebody's behalf. + */ +/** + * Start a consent flow, and say which screen it started from. + * + * `returnTo` decides where the vendor's callback puts somebody down, because two screens offer this: + * a person's own connected-accounts page, and the connector's admin page where an administrator + * verifies the setup they have just finished. Sending an administrator to their personal settings + * afterwards is the round trip the inline row exists to remove. + * + * A name rather than a URL. The server narrows it to a known set before signing it into the state, + * so this parameter cannot become an open redirect however it is called. + */ +export function connectAccountMutationOptions( + returnTo: "settings" | "admin" = "settings", +) { + return mutationOptions({ + mutationFn: (serverId: string): Promise => + client( + `/api/plugins/servers/${encodeURIComponent(serverId)}/connect?returnTo=${returnTo}`, + "authorizationUrl", + { method: "POST", fallback: "That account could not be connected." }, + ), + }); +} + export function removeSkillMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: async (slug: string) => { diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index 14d3a36a..525dd7a7 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; -import { client, tryClient } from "@/lib/client"; +import { client } from "@/lib/client"; /** A tool one server offers, as the Plugins page sees it. */ export type PluginTool = { @@ -49,7 +49,14 @@ export type CatalogueItem = { vendor: string; summary: string; docsUrl: string; - needsCredential: boolean; + /** + * Whose credential reaches this server. + * + * `deployment-bearer` is a token an administrator holds for everybody, and the only one this page + * can collect. `user-oauth` is reached as whoever is asking, so each person connects their own + * account and there is no token to type here. + */ + auth: "none" | "deployment-bearer" | "user-oauth"; /** True for a vendor that gives every customer their own hostname. */ perInstance: boolean; }; @@ -58,6 +65,14 @@ export type PluginsPage = { catalogue: CatalogueItem[]; servers: PluginServer[]; skills: PluginSkill[]; + /** + * The redirect URI to register with a `user-oauth` vendor, exactly as this deployment will send it. + * + * From the server rather than assembled here, because it has to match what was registered + * character for character. Null when the deployment has no public URL and so cannot complete a + * consent flow at all. + */ + redirectUri: string | null; }; /** What one Bot holds, which is all the runtime needs to offer it. */ @@ -80,8 +95,40 @@ export const pluginKeys = { all: ["plugins"] as const, page: () => ["plugins", "page"] as const, forAgent: (agentId: string) => ["plugins", "for-agent", agentId] as const, + connections: () => ["plugins", "connections"] as const, }; +/** One account this person has connected, from their own point of view. */ +export type PluginConnection = { + serverId: string; + /** What the vendor actually granted, which is not always what was asked for. */ + scope: string; + connectedAt: string; +}; + +export type PluginConnections = { + connections: PluginConnection[]; + redirectUri: string | null; +}; + +/** + * The signed-in person's own connections. + * + * There is no version of this scoped to anybody else: the endpoint answers for whoever is asking, + * so a page cannot accidentally render somebody else's. + */ +export function connectionsQueryOptions() { + return queryOptions({ + queryKey: pluginKeys.connections(), + queryFn: async (): Promise => { + const response = await client("/api/plugins/connections", { + fallback: "Your connected accounts could not be loaded.", + }); + return response.json(); + }, + }); +} + export function pluginsPageQueryOptions() { return queryOptions({ queryKey: pluginKeys.page(), @@ -111,55 +158,3 @@ export function agentPluginsQueryOptions(agentId: string) { }, }); } - -export type PluginCallOutcome = - | { ok: true; text: string; isError: boolean } - /** The deployment decided against it. `rule` names the expression, when one decided. */ - | { ok: false; refused: true; reason: string; rule: string | null } - /** Remote server failure; distinct from a policy refusal. */ - | { ok: false; refused: false; reason: string }; - -/** - * Call a tool as a Bot, with server-side grant and policy rechecks for mid-run revocations. - */ -export async function callPluginTool( - ref: string, - args: Record, - agentId: string, - signal?: AbortSignal, -): Promise { - /* A refused tool is an outcome this returns, not an error it throws. */ - const response = await tryClient("/api/plugins/call", { - method: "POST", - body: { ref, args, agentId }, - signal, - }); - - const body = (await response.json().catch(() => null)) as { - text?: string; - isError?: boolean; - error?: string; - rule?: string | null; - } | null; - - if (response.ok) { - return { - ok: true, - text: body?.text ?? "", - isError: body?.isError === true, - }; - } - if (response.status === 403) { - return { - ok: false, - refused: true, - reason: body?.error ?? "That tool is not allowed here.", - rule: body?.rule ?? null, - }; - } - return { - ok: false, - refused: false, - reason: body?.error ?? "The server did not answer.", - }; -} diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index c7fae134..5cb09822 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -21,21 +21,24 @@ import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/ind import { Route as AuthedAdminAuditRouteImport } from './routes/_authed/admin/audit' import { Route as AuthedAdminBoundariesRouteImport } from './routes/_authed/admin/boundaries' import { Route as AuthedAdminComputersRouteImport } from './routes/_authed/admin/computers' -import { Route as AuthedAdminConnectorsRouteImport } from './routes/_authed/admin/connectors' import { Route as AuthedAdminCredentialsRouteImport } from './routes/_authed/admin/credentials' import { Route as AuthedAdminIdentityProvidersRouteImport } from './routes/_authed/admin/identity-providers' import { Route as AuthedAdminPeopleRouteImport } from './routes/_authed/admin/people' import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admin/playground' -import { Route as AuthedAdminPluginsRouteImport } from './routes/_authed/admin/plugins' +import { Route as AuthedAdminSkillsRouteImport } from './routes/_authed/admin/skills' import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settings/index' import { Route as AuthedAppAgentsIndexRouteImport } from './routes/_authed/_app/agents/index' import { Route as AuthedAppChannelChannelIdRouteImport } from './routes/_authed/_app/channel/$channelId' import { Route as AuthedAppChannelNewRouteImport } from './routes/_authed/_app/channel/new' import { Route as AuthedAdminComponentsIndexRouteImport } from './routes/_authed/admin/components/index' import { Route as AuthedAdminComponentsNameRouteImport } from './routes/_authed/admin/components/$name' -import { Route as AuthedAdminConnectorsGoogleDriveRouteImport } from './routes/_authed/admin/connectors/google-drive' +import { Route as AuthedAdminPluginsIndexRouteImport } from './routes/_authed/admin/plugins/index' +import { Route as AuthedAdminPluginsKeyRouteImport } from './routes/_authed/admin/plugins/$key' import { Route as AuthedSettingsComponentsGalleryIndexRouteImport } from './routes/_authed/settings/components-gallery/index' import { Route as AuthedSettingsComponentsGalleryNameRouteImport } from './routes/_authed/settings/components-gallery/$name' +import { Route as AuthedSettingsConnectedAccountsIndexRouteImport } from './routes/_authed/settings/connected-accounts/index' +import { Route as AuthedSettingsConnectedAccountsKeyRouteImport } from './routes/_authed/settings/connected-accounts/$key' +import { Route as AuthedAdminPluginsKeyToolsToolRouteImport } from './routes/_authed/admin/plugins/$key_.tools.$tool' const AuthedRoute = AuthedRouteImport.update({ id: '/_authed', @@ -95,11 +98,6 @@ const AuthedAdminComputersRoute = AuthedAdminComputersRouteImport.update({ path: '/computers', getParentRoute: () => AuthedAdminRouteRoute, } as any) -const AuthedAdminConnectorsRoute = AuthedAdminConnectorsRouteImport.update({ - id: '/connectors', - path: '/connectors', - getParentRoute: () => AuthedAdminRouteRoute, -} as any) const AuthedAdminCredentialsRoute = AuthedAdminCredentialsRouteImport.update({ id: '/credentials', path: '/credentials', @@ -121,9 +119,9 @@ const AuthedAdminPlaygroundRoute = AuthedAdminPlaygroundRouteImport.update({ path: '/playground', getParentRoute: () => AuthedAdminRouteRoute, } as any) -const AuthedAdminPluginsRoute = AuthedAdminPluginsRouteImport.update({ - id: '/plugins', - path: '/plugins', +const AuthedAdminSkillsRoute = AuthedAdminSkillsRouteImport.update({ + id: '/skills', + path: '/skills', getParentRoute: () => AuthedAdminRouteRoute, } as any) const AuthedSettingsIndexRoute = AuthedSettingsIndexRouteImport.update({ @@ -159,12 +157,16 @@ const AuthedAdminComponentsNameRoute = path: '/components/$name', getParentRoute: () => AuthedAdminRouteRoute, } as any) -const AuthedAdminConnectorsGoogleDriveRoute = - AuthedAdminConnectorsGoogleDriveRouteImport.update({ - id: '/google-drive', - path: '/google-drive', - getParentRoute: () => AuthedAdminConnectorsRoute, - } as any) +const AuthedAdminPluginsIndexRoute = AuthedAdminPluginsIndexRouteImport.update({ + id: '/plugins/', + path: '/plugins/', + getParentRoute: () => AuthedAdminRouteRoute, +} as any) +const AuthedAdminPluginsKeyRoute = AuthedAdminPluginsKeyRouteImport.update({ + id: '/plugins/$key', + path: '/plugins/$key', + getParentRoute: () => AuthedAdminRouteRoute, +} as any) const AuthedSettingsComponentsGalleryIndexRoute = AuthedSettingsComponentsGalleryIndexRouteImport.update({ id: '/components-gallery/', @@ -177,6 +179,24 @@ const AuthedSettingsComponentsGalleryNameRoute = path: '/components-gallery/$name', getParentRoute: () => AuthedSettingsRouteRoute, } as any) +const AuthedSettingsConnectedAccountsIndexRoute = + AuthedSettingsConnectedAccountsIndexRouteImport.update({ + id: '/connected-accounts/', + path: '/connected-accounts/', + getParentRoute: () => AuthedSettingsRouteRoute, + } as any) +const AuthedSettingsConnectedAccountsKeyRoute = + AuthedSettingsConnectedAccountsKeyRouteImport.update({ + id: '/connected-accounts/$key', + path: '/connected-accounts/$key', + getParentRoute: () => AuthedSettingsRouteRoute, + } as any) +const AuthedAdminPluginsKeyToolsToolRoute = + AuthedAdminPluginsKeyToolsToolRouteImport.update({ + id: '/plugins/$key_/tools/$tool', + path: '/plugins/$key/tools/$tool', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof AuthedAppIndexRoute @@ -188,22 +208,25 @@ export interface FileRoutesByFullPath { '/admin/audit': typeof AuthedAdminAuditRoute '/admin/boundaries': typeof AuthedAdminBoundariesRoute '/admin/computers': typeof AuthedAdminComputersRoute - '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren '/admin/credentials': typeof AuthedAdminCredentialsRoute '/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute - '/admin/plugins': typeof AuthedAdminPluginsRoute + '/admin/skills': typeof AuthedAdminSkillsRoute '/admin/': typeof AuthedAdminIndexRoute '/settings/': typeof AuthedSettingsIndexRoute '/channel/$channelId': typeof AuthedAppChannelChannelIdRoute '/channel/new': typeof AuthedAppChannelNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute - '/admin/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute + '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute + '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute '/agents/': typeof AuthedAppAgentsIndexRoute '/admin/components/': typeof AuthedAdminComponentsIndexRoute + '/admin/plugins/': typeof AuthedAdminPluginsIndexRoute '/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute + '/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute + '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRoutesByTo { '/': typeof AuthedAppIndexRoute @@ -213,22 +236,25 @@ export interface FileRoutesByTo { '/admin/audit': typeof AuthedAdminAuditRoute '/admin/boundaries': typeof AuthedAdminBoundariesRoute '/admin/computers': typeof AuthedAdminComputersRoute - '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren '/admin/credentials': typeof AuthedAdminCredentialsRoute '/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute - '/admin/plugins': typeof AuthedAdminPluginsRoute + '/admin/skills': typeof AuthedAdminSkillsRoute '/admin': typeof AuthedAdminIndexRoute '/settings': typeof AuthedSettingsIndexRoute '/channel/$channelId': typeof AuthedAppChannelChannelIdRoute '/channel/new': typeof AuthedAppChannelNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute - '/admin/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute + '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute + '/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute '/agents': typeof AuthedAppAgentsIndexRoute '/admin/components': typeof AuthedAdminComponentsIndexRoute + '/admin/plugins': typeof AuthedAdminPluginsIndexRoute '/settings/components-gallery': typeof AuthedSettingsComponentsGalleryIndexRoute + '/settings/connected-accounts': typeof AuthedSettingsConnectedAccountsIndexRoute + '/admin/plugins/$key/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -242,23 +268,26 @@ export interface FileRoutesById { '/_authed/admin/audit': typeof AuthedAdminAuditRoute '/_authed/admin/boundaries': typeof AuthedAdminBoundariesRoute '/_authed/admin/computers': typeof AuthedAdminComputersRoute - '/_authed/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren '/_authed/admin/credentials': typeof AuthedAdminCredentialsRoute '/_authed/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute '/_authed/admin/people': typeof AuthedAdminPeopleRoute '/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute - '/_authed/admin/plugins': typeof AuthedAdminPluginsRoute + '/_authed/admin/skills': typeof AuthedAdminSkillsRoute '/_authed/_app/': typeof AuthedAppIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/settings/': typeof AuthedSettingsIndexRoute '/_authed/_app/channel/$channelId': typeof AuthedAppChannelChannelIdRoute '/_authed/_app/channel/new': typeof AuthedAppChannelNewRoute '/_authed/admin/components/$name': typeof AuthedAdminComponentsNameRoute - '/_authed/admin/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute + '/_authed/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute '/_authed/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute + '/_authed/settings/connected-accounts/$key': typeof AuthedSettingsConnectedAccountsKeyRoute '/_authed/_app/agents/': typeof AuthedAppAgentsIndexRoute '/_authed/admin/components/': typeof AuthedAdminComponentsIndexRoute + '/_authed/admin/plugins/': typeof AuthedAdminPluginsIndexRoute '/_authed/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute + '/_authed/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute + '/_authed/admin/plugins/$key_/tools/$tool': typeof AuthedAdminPluginsKeyToolsToolRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -272,22 +301,25 @@ export interface FileRouteTypes { | '/admin/audit' | '/admin/boundaries' | '/admin/computers' - | '/admin/connectors' | '/admin/credentials' | '/admin/identity-providers' | '/admin/people' | '/admin/playground' - | '/admin/plugins' + | '/admin/skills' | '/admin/' | '/settings/' | '/channel/$channelId' | '/channel/new' | '/admin/components/$name' - | '/admin/connectors/google-drive' + | '/admin/plugins/$key' | '/settings/components-gallery/$name' + | '/settings/connected-accounts/$key' | '/agents/' | '/admin/components/' + | '/admin/plugins/' | '/settings/components-gallery/' + | '/settings/connected-accounts/' + | '/admin/plugins/$key/tools/$tool' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -297,22 +329,25 @@ export interface FileRouteTypes { | '/admin/audit' | '/admin/boundaries' | '/admin/computers' - | '/admin/connectors' | '/admin/credentials' | '/admin/identity-providers' | '/admin/people' | '/admin/playground' - | '/admin/plugins' + | '/admin/skills' | '/admin' | '/settings' | '/channel/$channelId' | '/channel/new' | '/admin/components/$name' - | '/admin/connectors/google-drive' + | '/admin/plugins/$key' | '/settings/components-gallery/$name' + | '/settings/connected-accounts/$key' | '/agents' | '/admin/components' + | '/admin/plugins' | '/settings/components-gallery' + | '/settings/connected-accounts' + | '/admin/plugins/$key/tools/$tool' id: | '__root__' | '/_authed' @@ -325,23 +360,26 @@ export interface FileRouteTypes { | '/_authed/admin/audit' | '/_authed/admin/boundaries' | '/_authed/admin/computers' - | '/_authed/admin/connectors' | '/_authed/admin/credentials' | '/_authed/admin/identity-providers' | '/_authed/admin/people' | '/_authed/admin/playground' - | '/_authed/admin/plugins' + | '/_authed/admin/skills' | '/_authed/_app/' | '/_authed/admin/' | '/_authed/settings/' | '/_authed/_app/channel/$channelId' | '/_authed/_app/channel/new' | '/_authed/admin/components/$name' - | '/_authed/admin/connectors/google-drive' + | '/_authed/admin/plugins/$key' | '/_authed/settings/components-gallery/$name' + | '/_authed/settings/connected-accounts/$key' | '/_authed/_app/agents/' | '/_authed/admin/components/' + | '/_authed/admin/plugins/' | '/_authed/settings/components-gallery/' + | '/_authed/settings/connected-accounts/' + | '/_authed/admin/plugins/$key_/tools/$tool' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -435,13 +473,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminComputersRouteImport parentRoute: typeof AuthedAdminRouteRoute } - '/_authed/admin/connectors': { - id: '/_authed/admin/connectors' - path: '/connectors' - fullPath: '/admin/connectors' - preLoaderRoute: typeof AuthedAdminConnectorsRouteImport - parentRoute: typeof AuthedAdminRouteRoute - } '/_authed/admin/credentials': { id: '/_authed/admin/credentials' path: '/credentials' @@ -470,11 +501,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminPlaygroundRouteImport parentRoute: typeof AuthedAdminRouteRoute } - '/_authed/admin/plugins': { - id: '/_authed/admin/plugins' - path: '/plugins' - fullPath: '/admin/plugins' - preLoaderRoute: typeof AuthedAdminPluginsRouteImport + '/_authed/admin/skills': { + id: '/_authed/admin/skills' + path: '/skills' + fullPath: '/admin/skills' + preLoaderRoute: typeof AuthedAdminSkillsRouteImport parentRoute: typeof AuthedAdminRouteRoute } '/_authed/settings/': { @@ -519,12 +550,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminComponentsNameRouteImport parentRoute: typeof AuthedAdminRouteRoute } - '/_authed/admin/connectors/google-drive': { - id: '/_authed/admin/connectors/google-drive' - path: '/google-drive' - fullPath: '/admin/connectors/google-drive' - preLoaderRoute: typeof AuthedAdminConnectorsGoogleDriveRouteImport - parentRoute: typeof AuthedAdminConnectorsRoute + '/_authed/admin/plugins/': { + id: '/_authed/admin/plugins/' + path: '/plugins' + fullPath: '/admin/plugins/' + preLoaderRoute: typeof AuthedAdminPluginsIndexRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } + '/_authed/admin/plugins/$key': { + id: '/_authed/admin/plugins/$key' + path: '/plugins/$key' + fullPath: '/admin/plugins/$key' + preLoaderRoute: typeof AuthedAdminPluginsKeyRouteImport + parentRoute: typeof AuthedAdminRouteRoute } '/_authed/settings/components-gallery/': { id: '/_authed/settings/components-gallery/' @@ -540,50 +578,62 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedSettingsComponentsGalleryNameRouteImport parentRoute: typeof AuthedSettingsRouteRoute } + '/_authed/settings/connected-accounts/': { + id: '/_authed/settings/connected-accounts/' + path: '/connected-accounts' + fullPath: '/settings/connected-accounts/' + preLoaderRoute: typeof AuthedSettingsConnectedAccountsIndexRouteImport + parentRoute: typeof AuthedSettingsRouteRoute + } + '/_authed/settings/connected-accounts/$key': { + id: '/_authed/settings/connected-accounts/$key' + path: '/connected-accounts/$key' + fullPath: '/settings/connected-accounts/$key' + preLoaderRoute: typeof AuthedSettingsConnectedAccountsKeyRouteImport + parentRoute: typeof AuthedSettingsRouteRoute + } + '/_authed/admin/plugins/$key_/tools/$tool': { + id: '/_authed/admin/plugins/$key_/tools/$tool' + path: '/plugins/$key/tools/$tool' + fullPath: '/admin/plugins/$key/tools/$tool' + preLoaderRoute: typeof AuthedAdminPluginsKeyToolsToolRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } } } -interface AuthedAdminConnectorsRouteChildren { - AuthedAdminConnectorsGoogleDriveRoute: typeof AuthedAdminConnectorsGoogleDriveRoute -} - -const AuthedAdminConnectorsRouteChildren: AuthedAdminConnectorsRouteChildren = { - AuthedAdminConnectorsGoogleDriveRoute: AuthedAdminConnectorsGoogleDriveRoute, -} - -const AuthedAdminConnectorsRouteWithChildren = - AuthedAdminConnectorsRoute._addFileChildren( - AuthedAdminConnectorsRouteChildren, - ) - interface AuthedAdminRouteRouteChildren { AuthedAdminAuditRoute: typeof AuthedAdminAuditRoute AuthedAdminBoundariesRoute: typeof AuthedAdminBoundariesRoute AuthedAdminComputersRoute: typeof AuthedAdminComputersRoute - AuthedAdminConnectorsRoute: typeof AuthedAdminConnectorsRouteWithChildren AuthedAdminCredentialsRoute: typeof AuthedAdminCredentialsRoute AuthedAdminIdentityProvidersRoute: typeof AuthedAdminIdentityProvidersRoute AuthedAdminPeopleRoute: typeof AuthedAdminPeopleRoute AuthedAdminPlaygroundRoute: typeof AuthedAdminPlaygroundRoute - AuthedAdminPluginsRoute: typeof AuthedAdminPluginsRoute + AuthedAdminSkillsRoute: typeof AuthedAdminSkillsRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedAdminComponentsNameRoute: typeof AuthedAdminComponentsNameRoute + AuthedAdminPluginsKeyRoute: typeof AuthedAdminPluginsKeyRoute AuthedAdminComponentsIndexRoute: typeof AuthedAdminComponentsIndexRoute + AuthedAdminPluginsIndexRoute: typeof AuthedAdminPluginsIndexRoute + AuthedAdminPluginsKeyToolsToolRoute: typeof AuthedAdminPluginsKeyToolsToolRoute } const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = { AuthedAdminAuditRoute: AuthedAdminAuditRoute, AuthedAdminBoundariesRoute: AuthedAdminBoundariesRoute, AuthedAdminComputersRoute: AuthedAdminComputersRoute, - AuthedAdminConnectorsRoute: AuthedAdminConnectorsRouteWithChildren, AuthedAdminCredentialsRoute: AuthedAdminCredentialsRoute, AuthedAdminIdentityProvidersRoute: AuthedAdminIdentityProvidersRoute, AuthedAdminPeopleRoute: AuthedAdminPeopleRoute, AuthedAdminPlaygroundRoute: AuthedAdminPlaygroundRoute, - AuthedAdminPluginsRoute: AuthedAdminPluginsRoute, + AuthedAdminSkillsRoute: AuthedAdminSkillsRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedAdminComponentsNameRoute: AuthedAdminComponentsNameRoute, + AuthedAdminPluginsKeyRoute: AuthedAdminPluginsKeyRoute, AuthedAdminComponentsIndexRoute: AuthedAdminComponentsIndexRoute, + AuthedAdminPluginsIndexRoute: AuthedAdminPluginsIndexRoute, + AuthedAdminPluginsKeyToolsToolRoute: AuthedAdminPluginsKeyToolsToolRoute, } const AuthedAdminRouteRouteWithChildren = @@ -592,15 +642,21 @@ const AuthedAdminRouteRouteWithChildren = interface AuthedSettingsRouteRouteChildren { AuthedSettingsIndexRoute: typeof AuthedSettingsIndexRoute AuthedSettingsComponentsGalleryNameRoute: typeof AuthedSettingsComponentsGalleryNameRoute + AuthedSettingsConnectedAccountsKeyRoute: typeof AuthedSettingsConnectedAccountsKeyRoute AuthedSettingsComponentsGalleryIndexRoute: typeof AuthedSettingsComponentsGalleryIndexRoute + AuthedSettingsConnectedAccountsIndexRoute: typeof AuthedSettingsConnectedAccountsIndexRoute } const AuthedSettingsRouteRouteChildren: AuthedSettingsRouteRouteChildren = { AuthedSettingsIndexRoute: AuthedSettingsIndexRoute, AuthedSettingsComponentsGalleryNameRoute: AuthedSettingsComponentsGalleryNameRoute, + AuthedSettingsConnectedAccountsKeyRoute: + AuthedSettingsConnectedAccountsKeyRoute, AuthedSettingsComponentsGalleryIndexRoute: AuthedSettingsComponentsGalleryIndexRoute, + AuthedSettingsConnectedAccountsIndexRoute: + AuthedSettingsConnectedAccountsIndexRoute, } const AuthedSettingsRouteRouteWithChildren = diff --git a/app/src/routes/_authed/admin/connectors.tsx b/app/src/routes/_authed/admin/connectors.tsx deleted file mode 100644 index 53b0eadd..00000000 --- a/app/src/routes/_authed/admin/connectors.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { IconBrandGoogleDrive, IconCloud } from "@tabler/icons-react"; -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute, Link } from "@tanstack/react-router"; -import { - PageEmpty, - PageRows, - PageSection, - PageShell, -} from "@/components/layout/page-shell"; -import { StaggerItem } from "@/components/layout/stagger"; -import { Button } from "@/components/ui/button"; -import { - Item, - ItemActions, - ItemContent, - ItemDescription, - ItemMedia, - ItemTitle, -} from "@/components/ui/item"; -import { Separator } from "@/components/ui/separator"; -import { connectorListQueryOptions } from "@/lib/connectors/queries"; - -export const Route = createFileRoute("/_authed/admin/connectors")({ - component: ConnectorsPage, -}); - -function ConnectorsPage() { - const connectors = useQuery(connectorListQueryOptions()); - return ( - - - {connectors.isPending ? null : connectors.error ? ( -

- Could not load connectors. -

- ) : connectors.data?.length === 0 ? ( - - No connectors. They come from this deployment's knowledge sources. - - ) : ( - - {connectors.data?.map((connector, index) => ( - - - - {connector.type === "google_drive" ? ( - - ) : ( - - )} - - - {connector.name} - - Roots: {connector.roots.join(", ")} ·{" "} - {connector.configured ? "Configured" : "Not configured"} - - - - {connector.type === "google_drive" ? ( - - ) : ( - // Said rather than left blank, which would read as a control yet to arrive. - - No setup screen yet - - )} - - - {index !== (connectors.data?.length ?? 0) - 1 && } - - ))} - - )} -
-
- ); -} diff --git a/app/src/routes/_authed/admin/connectors/google-drive.tsx b/app/src/routes/_authed/admin/connectors/google-drive.tsx deleted file mode 100644 index 4bd8dad6..00000000 --- a/app/src/routes/_authed/admin/connectors/google-drive.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useForm } from "@tanstack/react-form"; -import { useMutation } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; -import { queryClient } from "@/query-client"; -import { z } from "zod"; -import { PageShell } from "@/components/layout/page-shell"; -import { Button } from "@/components/ui/button"; -import { - Field, - FieldError, - FieldGroup, - FieldLabel, -} from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { setUpGoogleDriveMutationOptions } from "@/lib/connectors/mutations"; - -export const Route = createFileRoute("/_authed/admin/connectors/google-drive")({ - component: GoogleDriveConnectorPage, -}); - -function GoogleDriveConnectorPage() { - const setup = useMutation(setUpGoogleDriveMutationOptions(queryClient)); - const form = useForm({ - defaultValues: { serviceAccountJson: "", impersonationSubject: "" }, - validators: { - onSubmit: z.object({ - serviceAccountJson: z - .string() - .trim() - .refine((value) => { - try { - const parsed: unknown = JSON.parse(value); - return Boolean( - parsed && typeof parsed === "object" && !Array.isArray(parsed), - ); - } catch { - return false; - } - }, "Paste a valid service-account JSON object."), - impersonationSubject: z - .string() - .email("Enter the Workspace account to impersonate."), - }), - }, - onSubmit: async ({ value }) => { - await setup.mutateAsync(value); - form.reset(); - }, - }); - return ( - /* - * THE FORM STAYS ON THE PAGE HERE, unlike the rest of admin. This route exists only to hold it — - * there is no list behind it to interrupt — so putting it in a dialog would mean navigating to a - * page whose only content immediately covers itself up. - */ - -
{ - event.preventDefault(); - form.handleSubmit(); - }} - > - - - {(field) => ( - - - Service account JSON key - -