diff --git a/AGENTS.md b/AGENTS.md index 128a8380..1175d9b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ Personal Agent Proxy holds the integrations and personal information an AI agent - **Model**: an integration from the catalog (`@proxy/integrations`: Email over IMAP and SMTP, Google's Drive, Docs, Sheets and Slides over Google's APIs, one sign-in for a Google account, Granola's meeting notes, Notion's pages and Linear's issues over their MCP servers, PostgreSQL and MySQL over their own protocols) has the actions an agent can be allowed with a connection of it (Read, Archive, Write drafts, Send) and collections, each a list of fields with the actions reading and writing it need, and the commands it offers beyond editing (Mark as read, Archive, Send), each needing one action. A command runs on a `record`, where its `where` picks out which (Archive only in the inbox, Send only on a draft), or on values typed in (`new`: sending a new email). A mailbox is one collection, its folder a field the agent side filters by. A `nested` collection is a tree, as Notion's pages are: a record can hold others of the collection (`hasChildren`), listed inside it with the list tool's `parent` and made inside it with the create tool's, and access is the same anywhere in it. A collection without a `read` action is never listed or opened, only run, as a database's queries are, and a `new` command marked `readOnly` counts as a read for MCP clients. Integrations join the catalog once they work end to end. Any other service with an MCP server is connected as a custom MCP server: the server's tools are the actions, each allowed on its own, and there are no collections; an agent runs a tool with the params the server describes and gets back what it answered. A connection is a connected integration and its account, owned by the organization; an integration can be connected more than once, such as two mailboxes. Information is the built-in `info` connection (addresses, payment cards, notes), made with the organization, so access and the agent side treat it like any other. Nothing is copied from a provider: every request goes to it live. Fields marked `system` are set by the provider, never typed in. - **Access** is one list of actions per connection, and every action the catalog lists can be set: what Personal Agent Proxy can do with the provider is the limit. Two layers: the connection's default (a `ConnectionDefault` row per action that is on) and an agent's own setting, on or off, where it differs (`AgentGrant`). An agent gets its own setting, else the default as it is now; an action that `requires` another counts only with it (Archive needs Read; Send needs nothing, so an agent can send without reading the mailbox), and an action a sign-in didn't cover, as Google's consent screen allows, is unavailable whatever is ticked. A collection's `writes` name the action creating, editing and deleting need, and `editable` which records they apply to (drafts). An agent login is for one agent from `AGENT_PROVIDERS`; each organization can have one login per company, named by it, and any number of custom MCP agents (`custom`, for any MCP client not in the list), each named by the person and told apart by their names (`Agent` is unique by organization, provider and name). It has a generated username and a password that is shown once. Every request on the agent side is an audit entry, allowed or denied, with what a list searched for. - **Accounts**: a human signs in with Google or a magic link (`src/auth/` in the api); the sign-in page says that continuing accepts the terms and the privacy policy, on the website, and a sign-up records it (`User.termsAcceptedAt`); accounts from before the notice have none until they are asked. A session is a `Session` row and an httpOnly cookie; the web app's `/api/me` loader on the human side's route redirects to `/login?callbackUrl=…` without one. Every user gets one `Organization` at sign-up, created with the user. `ALLOWED_SIGNUP_EMAILS` limits who can sign up; the login page asks `/auth/methods` whether to offer Google. In development the login page has a Create dev user button; in development, or without `RESEND_KEY`, the api prints magic links instead of emailing them. Cookies are secure when `APP_URL` is HTTPS. A new organization first goes through the welcome flow (`/welcome`, outside the human side's layout; the human side redirects there until `Organization.onboardedAt` is set, which `POST /api/me/onboarded` does when the flow is finished or skipped): step one is `AgentSetup` (`src/components/agent-setup.tsx`, which New agent and setting an agent up again use too): it picks the agent from `AGENT_PROVIDERS`, the choice in the address (`?agent=claude`) so the browser's back button returns to it, and does what its `connects` says, an `mcp` agent (Claude, Claude Code, Claude Desktop, Hermes Agent, ChatGPT, Poke) shown the server address and that agent's steps (Claude Code and Hermes Agent use terminal commands) while the page polls for the login its sign-in makes, and over http, as in development, only a `local` agent (Claude Code, Claude Desktop, Hermes Agent) can be picked, the rest greyed out with a pointer to self-hosting on a server with a domain, a `login` agent given a login at once, as a message to paste to it with the sign-in page, username and password, or, for a `vault` agent (Instinct), which won't take a password in chat, as three steps: a message asking it for a link to its vault, the login to fill in on the page it sends, and a message telling it to sign in; step two (`/welcome/connections`) is the catalog, a plus per integration with how many accounts are connected: a mailbox goes to `/welcome/connections/email`, the form on a page with Continue once signed in, and a sign-in started with `?returnTo=welcome` comes back to `/welcome/connections/:id`, the new connection's permissions (its defaults, saved as they are ticked) with Confirm back to the catalog; a database goes to `/welcome/connections/postgres` or `/welcome/connections/mysql` the same way as a mailbox. Organizations from before the flow were marked done by its migration. -- **Human side** behind `/login`: `/connections` (a right click on a row renames the connection, `PUT /api/connections/:id/name`, an empty name being none of its own, which a custom MCP server can't have, or deletes it, each behind a dialog), `/connections/new` (the catalog, a plus per integration with how many accounts are connected: `/connections/new/email` connects a mailbox with an app password, with a Test connection that signs in and saves nothing, `/connections/new/postgres` and `/connections/new/mysql` a database the same way, `/connections/new/mcp` a custom MCP server under a name of the person's, by its address, with an access token where the server takes one instead of a sign-in (`ConnectMcpForm`, which sends the browser to the server's sign-in when the api says it asks for one), and Google, Granola, Notion and Linear are connected by signing in to them; `ConnectEmailForm`, `ConnectDatabaseForm`, `ConnectMcpForm` and `IntegrationCatalog` serve the welcome flow too, at `/welcome/connections/`), `/connections/:id` (titled by the name the person gave it where they did, as the list and the agent side are, the defaults under Permissions, with how many agents differ per action, a custom MCP server's tools being its actions, with a button that asks the server for them again, and a "Connection added successfully" when `?added` says it was just connected), `/info`, `/agents`, `/agents/new` (the welcome flow's agent step, among the companies without a login yet and Custom MCP agent, on to the agent's page), `/agents/:id/setup` (the same for one agent, to hand it a new password or add the connector again; the only place a password shows, once), `/agents/:id` (what the agent can reach and did, the MCP clients working as it when there are any; access laid out like an editor's settings: one checkbox per action of each connection, the only control for it, each connection folded by default to a row saying Default or Changed; a setting of the agent's own is marked with a bar, a button that makes it the connection's default for every agent, and one that resets it, and a filter and Changed only narrow the list; `ConnectionAccess` serves both pages), `/activity` (filters in the URL, so the agent and connection pages link to their part of the log; a custom server's tool reads "Ran Search docs with {…}"). Each page has a loader in `src/loaders.ts` and revalidates after a change. Layout follows the spanish repo: the shadcn sidebar as a rail, an `h-12` header, one `max-w-3xl` column, one-line `RowList` rows. Type has one size, `text-sm`, in rows, summaries and hints alike, except column headers (`RowHeader`), which are `text-xs`: the thing itself in the normal colour, what is said about it (column headers, times, summaries, descriptions) in `text-muted-foreground`, and `font-medium` only for page and section titles. +- **Human side** behind `/login`: `/connections` (a right click on a row renames the connection, `PUT /api/connections/:id/name`, an empty name being none of its own, which a custom MCP server can't have, or deletes it, each behind a dialog), `/connections/new` (the catalog, a plus per integration with how many accounts are connected: `/connections/new/email` connects a mailbox with an app password, with a Test connection that signs in and saves nothing, `/connections/new/postgres` and `/connections/new/mysql` a database the same way, `/connections/new/mcp` a custom MCP server under a name of the person's, by its address, with an access token where the server takes one instead of a sign-in (`ConnectMcpForm`, which sends the browser to the server's sign-in when the api says it asks for one), and Google, Granola, Notion and Linear are connected by signing in to them; an integration needing a client the server doesn't have (Google without `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`, which `/auth/methods` reports) is greyed out with a pointer to the docs, and its sign-in, reached anyway, comes back with `?error=google-unconfigured` saying the same; `ConnectEmailForm`, `ConnectDatabaseForm`, `ConnectMcpForm` and `IntegrationCatalog` serve the welcome flow too, at `/welcome/connections/`), `/connections/:id` (titled by the name the person gave it where they did, as the list and the agent side are, the defaults under Permissions, with how many agents differ per action, a custom MCP server's tools being its actions, with a button that asks the server for them again, and a "Connection added successfully" when `?added` says it was just connected), `/info`, `/agents`, `/agents/new` (the welcome flow's agent step, among the companies without a login yet and Custom MCP agent, on to the agent's page), `/agents/:id/setup` (the same for one agent, to hand it a new password or add the connector again; the only place a password shows, once), `/agents/:id` (what the agent can reach and did, the MCP clients working as it when there are any; access laid out like an editor's settings: one checkbox per action of each connection, the only control for it, each connection folded by default to a row saying Default or Changed; a setting of the agent's own is marked with a bar, a button that makes it the connection's default for every agent, and one that resets it, and a filter and Changed only narrow the list; `ConnectionAccess` serves both pages), `/activity` (filters in the URL, so the agent and connection pages link to their part of the log; a custom server's tool reads "Ran Search docs with {…}"). Each page has a loader in `src/loaders.ts` and revalidates after a change. Layout follows the spanish repo: the shadcn sidebar as a rail, an `h-12` header, one `max-w-3xl` column, one-line `RowList` rows. Type has one size, `text-sm`, in rows, summaries and hints alike, except column headers (`RowHeader`), which are `text-xs`: the thing itself in the normal colour, what is said about it (column headers, times, summaries, descriptions) in `text-muted-foreground`, and `font-medium` only for page and section titles. - **MCP**: an agent that speaks MCP (Poke, Claude, Claude Code, Claude Desktop, Hermes Agent, ChatGPT, or any other as a Custom MCP agent) adds `/mcp` as a custom connector and signs in; the person picks which agent login it works as on `/connect-agent`, or makes one there: a client whose name points at no company's agent is offered a new custom agent named after it, which the person can rename. Setting up a custom agent waits for one that wasn't there before to sign in, since there can be several. The agent page shows the server's address and the MCP clients working as the login, each with a Disconnect; the activity log marks requests that came in over MCP (`AuditEntry.via`). - **Agent side** behind `/agent/login`, with its own cookie: `/agent` lists what the login can do with each connection and the lists it can open, `/agent/:connection/tool/:tool` one of a custom MCP server's tools, a form of its params built from the tool's schema and what it answered below (the home page lists the tools the login may run), `/agent/:connection/:collection` a collection's records (newest first, 50 to a page with an Older link, narrowed by its `filterField`, such as the folder, in the address as the list tool takes it, `?folder=Draft`, and searched where the collection has a `searchHint`, which says what the search takes; in a nested collection `?parent=` opens it inside a record, with the records above it as breadcrumbs, a record holding others opening into them, and New making one there), `…/new` and `…/:record` the generic `RecordForm` and `RecordFields`, with a button per command the agent may run on the record, Send beside Save as draft on a new email, and a command that answers something, as running a query does, shown under the form (loaders in `src/agent-loaders.ts`). Every page runs a tool (`toolName` in `@proxy/integrations`) through the api. It is built for a model driving a browser: no sidebar, no icon-only buttons, nothing that only shows on hover. Lists it can neither read nor add to are left out, and one it can only add to (sending without Read) opens on its form; asking for one anyway shows the api's refusal, which it logs as denied. - **Testing email locally**: any IMAP server with TLS on 993 and SMTP on 465 or 587 works through Other. A self-signed one needs `NODE_TLS_REJECT_UNAUTHORIZED=0` on the api, never in production. The server's hostname needs a dot and can't be an IP address: `localhost4.localdomain4` reaches 127.0.0.1 on most Linux machines. diff --git a/packages/api/src/auth/google.test.ts b/packages/api/src/auth/google.test.ts index 8fb66448..fb15f620 100644 --- a/packages/api/src/auth/google.test.ts +++ b/packages/api/src/auth/google.test.ts @@ -152,12 +152,21 @@ describe('handleSignInMethodsRoute', () => { const configured = makeRes(); handleSignInMethodsRoute({} as never, configured as never); - expect(configured.json).toHaveBeenCalledWith({google: true}); + expect(configured.json).toHaveBeenCalledWith({google: true, needs: {google: true}}); mockEnv.GOOGLE_CLIENT_ID = undefined; const unconfigured = makeRes(); handleSignInMethodsRoute({} as never, unconfigured as never); - expect(unconfigured.json).toHaveBeenCalledWith({google: false}); + expect(unconfigured.json).toHaveBeenCalledWith({google: false, needs: {google: false}}); + }); + + test('says a Google connection can start with the client alone, as its start route does', async () => { + const {handleSignInMethodsRoute} = await importRoutes(); + mockEnv.APP_URL = undefined; + + const res = makeRes(); + handleSignInMethodsRoute({} as never, res as never); + expect(res.json).toHaveBeenCalledWith({google: false, needs: {google: true}}); }); }); diff --git a/packages/api/src/auth/google.ts b/packages/api/src/auth/google.ts index 0be84d21..e6442379 100644 --- a/packages/api/src/auth/google.ts +++ b/packages/api/src/auth/google.ts @@ -62,9 +62,14 @@ function getOAuthConfig(): OAuthConfig | null { }; } -/** `GET /auth/methods` — the sign-in methods beside the magic link, so the login page offers only those set up. */ +/** + * `GET /auth/methods` — the sign-in methods beside the magic link, so the login page offers only + * those set up, and what the server has of what integrations need (`needs`), so the catalog offers + * only the ones whose sign-in can start: a Google connection needs the client, as its start route + * checks. + */ export function handleSignInMethodsRoute(_req: Request, res: Response): void { - res.json({google: getOAuthConfig() !== null}); + res.json({google: getOAuthConfig() !== null, needs: {google: googleOAuthClient() !== null}}); } const statePayloadSchema = z.object({ diff --git a/packages/api/src/connections/connect-flow.ts b/packages/api/src/connections/connect-flow.ts index 136b24c8..a678eee8 100644 --- a/packages/api/src/connections/connect-flow.ts +++ b/packages/api/src/connections/connect-flow.ts @@ -26,7 +26,8 @@ import {requireUserOrgId} from '../utils/user-org'; * callback checks the provider's answer against the cookie, trades the code for tokens and makes * the connection, or gives an existing one of the same account its new tokens, which is how * signing in again works, and ends on the connection's page, or back on the catalog with - * `?error=`; a sign-in begun from the welcome flow (`returnTo: 'welcome'`) ends on its + * `?error=` (with `?error=-unconfigured` when the server lacks what the + * sign-in needs, such as Google's client); a sign-in begun from the welcome flow (`returnTo: 'welcome'`) ends on its * page for the connection, or its catalog, instead. What the provider's sign-in page is and how a * code becomes tokens and a connection is the integration's. */ @@ -96,6 +97,14 @@ export function saveConnection(orgId: string, integrationId: IntegrationId, c }); } +// Where the sign-in ends: the catalog, or the welcome flow's connections step it started from. +function catalogUrl(appUrl: string, returnTo: ReturnTo | undefined): string { + if (returnTo === 'welcome') { + return `${appUrl}/welcome/connections`; + } + return `${appUrl}/connections/new`; +} + type SignInArgs = { integrationId: IntegrationId; // The provider's name in errors and logs: "Notion". @@ -136,14 +145,6 @@ export function signInFlow(args: SignInArgs) { }); } - // Where the sign-in ends: the catalog, or the welcome flow's connections step it started from. - function catalogUrl(appUrl: string, returnTo: ReturnTo | undefined): string { - if (returnTo === 'welcome') { - return `${appUrl}/welcome/connections`; - } - return `${appUrl}/connections/new`; - } - function redirectToConnectError(res: Response, appUrl: string, returnTo: ReturnTo | undefined, error: ApiError): void { log.warn(`Connecting ${name} failed`, {kind: error.kind, ...('message' in error ? {message: error.message} : {}), ...('cause' in error ? serializeError(error.cause) : {})}); res.redirect(`${catalogUrl(appUrl, returnTo)}?error=${integrationId}`); @@ -217,7 +218,14 @@ export function signInFlow(args: SignInArgs) { } /** The routes for a provider known in advance: the start, a top-level navigation, and the callback. */ -export function signInRoutes(args: SignInArgs & {start: SignInStart}): {handleStart: Route; handleCallback: Route} { +export function signInRoutes( + args: SignInArgs & { + // Whether the server has what the sign-in needs, such as Google's client: without it, the + // start goes back to the catalog saying so rather than trying. + configured?: () => boolean; + start: SignInStart; + }, +): {handleStart: Route; handleCallback: Route} { const flow = signInFlow(args); /** `GET /api/connections//start` — sends the browser to the provider's sign-in. */ @@ -229,6 +237,11 @@ export function signInRoutes(args: SignInArgs & {start: SignInStart< } const returnTo = returnToSchema.safeParse(queryParam(request, 'returnTo')).data; + if (args.configured && !args.configured()) { + res.redirect(`${catalogUrl(appUrl, returnTo)}?error=${args.integrationId}-unconfigured`); + return; + } + const begun = await flow.beginSignIn(res, {userId: request.user.userId, returnTo, account: queryParam(request, 'account'), start: args.start}); if (begun.isErr()) { flow.redirectToConnectError(res, appUrl, returnTo, begun.error); diff --git a/packages/api/src/connections/google/route.test.ts b/packages/api/src/connections/google/route.test.ts index db43b7cf..72130b5f 100644 --- a/packages/api/src/connections/google/route.test.ts +++ b/packages/api/src/connections/google/route.test.ts @@ -157,11 +157,13 @@ describe('handleStartGoogleRoute', () => { expect(none?.searchParams.has('login_hint')).toBe(false); }); - test('without a Google client set up, goes back to the catalog with the error', async () => { + test('without a Google client set up, goes back to the catalog saying so', async () => { env.GOOGLE_CLIENT_ID = undefined; const {res, cookie} = await start(); + const {res: welcome} = await start({returnTo: 'welcome'}); - expect(res.redirect).toHaveBeenCalledWith('https://app.example.com/connections/new?error=google'); + expect(res.redirect).toHaveBeenCalledWith('https://app.example.com/connections/new?error=google-unconfigured'); + expect(welcome.redirect).toHaveBeenCalledWith('https://app.example.com/welcome/connections?error=google-unconfigured'); expect(cookie).toBeUndefined(); }); }); diff --git a/packages/api/src/connections/google/route.ts b/packages/api/src/connections/google/route.ts index d2427db3..ce12379d 100644 --- a/packages/api/src/connections/google/route.ts +++ b/packages/api/src/connections/google/route.ts @@ -2,6 +2,7 @@ import {z} from 'zod'; import {Do} from '@proxy/utils'; +import {googleOAuthClient} from '../../utils/google-oauth-client'; import {signInRoutes} from '../connect-flow'; import {requireGoogleClient} from './google-client'; import {exchangeGoogleCode, googleAuthorizeUrl} from './google-oauth'; @@ -12,12 +13,13 @@ import {exchangeGoogleCode, googleAuthorizeUrl} from './google-oauth'; * with and is found by Google's id for the account, so signing in again gives it the new tokens * and the address as it is now, even after the address changed. A new Google connection lets * agents read Drive by default. Without a Google client set up, the start goes back to the catalog - * with the error. + * with `?error=google-unconfigured`, which the catalog turns into what the server is missing. */ export const {handleStart: handleStartGoogleRoute, handleCallback: handleGoogleCallbackRoute} = signInRoutes({ integrationId: 'google', name: 'Google', keptSchema: z.null(), + configured: () => googleOAuthClient() !== null, start: (request) => Do(async ($) => { return {url: googleAuthorizeUrl($(requireGoogleClient()), request), kept: null}; diff --git a/packages/api/src/server/serve-web.test.ts b/packages/api/src/server/serve-web.test.ts index d42ee420..6529717f 100644 --- a/packages/api/src/server/serve-web.test.ts +++ b/packages/api/src/server/serve-web.test.ts @@ -63,7 +63,7 @@ describe('the web app on the api origin', () => { test("leaves the api's paths to the api, with or without a trailing slash", async () => { expect(await (await fetch(`${base}/health`)).json()).toEqual({ok: true}); - expect(await (await fetch(`${base}/auth/methods`)).json()).toEqual({google: false}); + expect(await (await fetch(`${base}/auth/methods`)).json()).toEqual({google: false, needs: {google: false}}); for (const path of ['/api', '/api/nope', '/auth', '/auth/nope', '/agent-auth', '/agent-auth/nope']) { const res = await fetch(`${base}${path}`); diff --git a/packages/web/src/client/auth-client.ts b/packages/web/src/client/auth-client.ts index 807620de..44f9d779 100644 --- a/packages/web/src/client/auth-client.ts +++ b/packages/web/src/client/auth-client.ts @@ -26,11 +26,14 @@ export function requestMagicLink(email: string, callbackUrl?: string) { ); } -const signInMethodsSchema = z.object({google: z.boolean()}); +const signInMethodsSchema = z.object({google: z.boolean(), needs: z.object({google: z.boolean()})}); export type SignInMethods = z.infer; -/** The sign-in methods the api has set up besides the magic link, which always is. */ +/** + * The sign-in methods the api has set up besides the magic link, which always is, and which of + * what integrations need it has (`needs`), for the catalog. + */ export function getSignInMethods() { return httpRequest('/auth/methods', {method: 'GET'}, {schema: signInMethodsSchema}); } diff --git a/packages/web/src/components/integration-catalog.test.tsx b/packages/web/src/components/integration-catalog.test.tsx new file mode 100644 index 00000000..6b3368b8 --- /dev/null +++ b/packages/web/src/components/integration-catalog.test.tsx @@ -0,0 +1,91 @@ +/// + +import {afterEach, describe, expect, mock, test} from 'bun:test'; +import {cleanup, render, screen, within} from '@testing-library/react'; +import {TooltipProvider} from '@proxy/ui/components/tooltip'; +import {IntegrationCatalog, SignInFailed} from '@/components/integration-catalog'; +import type {Connection} from '@/lib/types'; + +const GOOGLE_DOCS = 'https://docs.personalagentproxy.com/connections/google/'; + +const google: Connection = { + id: 'conn-1', + integrationId: 'google', + account: 'alex@example.com', + defaults: ['readFiles'], + tools: null, + name: null, + unavailable: [], +}; + +afterEach(() => { + cleanup(); +}); + +function renderCatalog(args: {google: boolean; connections?: Connection[]}) { + render( + + + , + ); + const row = screen.getByText('Google').closest('li'); + if (!row) { + throw new Error('The catalog has no Google row'); + } + return within(row); +} + +describe('IntegrationCatalog', () => { + test('Google without its client on the server is greyed out, saying what to set and where the docs are', () => { + const row = renderCatalog({google: false, connections: [google]}); + + // The row is neither a link nor a button: its only link is to the docs. + expect(row.queryByRole('button')).toBeNull(); + expect(row.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([ + GOOGLE_DOCS, + ]); + expect(row.getByText(/Needs/).textContent).toBe( + 'Needs GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET on the server. See the docs.', + ); + const docs = row.getByRole('link', {name: 'the docs'}); + expect(docs.getAttribute('href')).toBe(GOOGLE_DOCS); + expect(docs.getAttribute('target')).toBe('_blank'); + expect(row.getByText('1 connected')).toBeDefined(); + }); + + test('Google with its client on the server is signed in to with the whole row', () => { + const row = renderCatalog({google: true}); + + expect(row.getByRole('link').getAttribute('href')).toBe('/api/connections/google/start'); + expect(row.queryByText(/GOOGLE_CLIENT_ID/)).toBeNull(); + }); +}); + +describe('SignInFailed', () => { + test('a sign-in that could not start says what the server is missing', () => { + render(); + + expect(screen.getByText(/Needs/).textContent).toBe( + 'Needs GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET on the server. See the docs.', + ); + expect(screen.getByRole('link', {name: 'the docs'}).getAttribute('href')).toBe(GOOGLE_DOCS); + }); + + test('says nothing of what the server was missing once it has it', () => { + const {container} = render( + , + ); + + expect(container.textContent).toBe(''); + }); + + test('a sign-in that failed says to try again', () => { + render(); + + expect(screen.getByText('Signing in to Google didn’t finish. Try again.')).toBeDefined(); + }); +}); diff --git a/packages/web/src/components/integration-catalog.tsx b/packages/web/src/components/integration-catalog.tsx index f90b3842..c6d7b9a2 100644 --- a/packages/web/src/components/integration-catalog.tsx +++ b/packages/web/src/components/integration-catalog.tsx @@ -4,7 +4,15 @@ import {cn} from '@proxy/ui/lib/utils'; import {PlusIcon} from 'lucide-react'; import {IntegrationLogo} from '@/components/brand-logo'; import {RowList} from '@/components/row-list'; -import {INTEGRATIONS, signInPath} from '@/lib/integrations'; +import { + INTEGRATIONS, + NEEDS, + signInFailedMessage, + signInPath, + unmetNeed, + type Configured, + type Need, +} from '@/lib/integrations'; import type {Connection} from '@/lib/types'; // The integrations not connected by signing in to them are connected on a form of their own: a @@ -12,22 +20,26 @@ import type {Connection} from '@/lib/types'; // its address. // The whole row is the link or button; its plus is styled as a ghost icon button that lights up -// with the row. +// with the row, and dimmed on a row greyed out. const ROW = 'group flex min-h-14 w-full items-center gap-3 px-4 py-2 text-left text-sm outline-none md:px-3'; +const PLUS_BUTTON = buttonVariants({variant: 'ghost', size: 'icon-sm'}); const PLUS = cn( - buttonVariants({variant: 'ghost', size: 'icon-sm'}), + PLUS_BUTTON, 'group-hover:bg-muted group-hover:text-foreground group-focus-visible:border-ring group-focus-visible:ring-3 group-focus-visible:ring-ring/50 group-active:translate-y-px dark:group-hover:bg-muted/50', ); // The catalog: every integration, a plus to connect it, and how many accounts are connected. Each -// can be connected again, for another mailbox, account or server. +// can be connected again, for another mailbox, account or server. One that needs what the server +// doesn't have, such as Google without its client, is greyed out, saying what to set up. export function IntegrationCatalog({ connections, + configured, returnTo, onConnectForm, }: { connections: Connection[]; + configured: Configured; returnTo?: 'welcome'; // The integration's form, by its id. onConnectForm: (integrationId: IntegrationId) => void; @@ -41,30 +53,42 @@ export function IntegrationCatalog({ (connection) => connection.integrationId === integration.id, ).length; const signIn = signInPath(integration.id, {returnTo}); + const unmet = + integration.needs && !configured[integration.needs] ? integration.needs : null; const content = ( <> - + - {integration.name} - + {integration.name} + {integration.description} + {unmet && ( + + + + )} {count > 0 && ( {count} connected )} - + ); return (
  • - {signIn ? ( + {unmet &&
    {content}
    } + {!unmet && signIn && ( {content} - ) : ( + )} + {!unmet && !signIn && ( @@ -75,3 +99,60 @@ export function IntegrationCatalog({ ); } + +// What the server needs set for an integration, with the docs page on setting it up. +function ServerNeeds({need}: {need: Need}) { + const {variables, docsUrl} = NEEDS[need]; + return ( + <> + Needs{' '} + {variables.map((variable, index) => ( + + {index > 0 && ' and '} + {variable} + + ))}{' '} + on the server. See{' '} + + the docs + + . + + ); +} + +// Why a sign-in sent back to the catalog with `?error=` didn't go through: the server lacking what +// the integration needs, said as the catalog's row says it, unless it has it by now, or the +// sign-in not finishing. +export function SignInFailed({ + error, + configured, + className, +}: { + error: string | null; + configured: Configured; + className?: string; +}) { + const unmet = unmetNeed(error); + if (unmet && configured[unmet]) { + return null; + } + if (unmet) { + return ( +

    + +

    + ); + } + + const failed = signInFailedMessage(error); + if (!failed) { + return null; + } + return

    {failed}

    ; +} diff --git a/packages/web/src/lib/integrations.ts b/packages/web/src/lib/integrations.ts index 0aaa267b..6ed0cc0a 100644 --- a/packages/web/src/lib/integrations.ts +++ b/packages/web/src/lib/integrations.ts @@ -13,7 +13,27 @@ import { // are, fills the tile edge to edge. type IntegrationLogoImage = {src: string; fill?: boolean}; -export type Integration = CatalogIntegration & {logo: LucideIcon | IntegrationLogoImage}; +export type Integration = CatalogIntegration & { + logo: LucideIcon | IntegrationLogoImage; + // What the server has to have before the integration can be connected, such as Google's client. + needs?: Need; +}; + +// What an integration can need from the server, and whether it has it: the answer of +// `/auth/methods`, since Google sign-in and Google connections share the client. +export type Need = 'google'; + +export type Configured = Record; + +// What each need is on the server, for a self-hoster to set up, and the docs page on it. +export const NEEDS: Record = { + google: { + variables: ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET'], + docsUrl: 'https://docs.personalagentproxy.com/connections/google/', + }, +}; + +const NEEDED_BY: Partial> = {google: 'google'}; const LOGOS: Record = { info: IdCardIcon, @@ -30,6 +50,7 @@ const LOGOS: Record = { export const INTEGRATIONS: Integration[] = CATALOG.map((integration) => ({ ...integration, logo: LOGOS[integration.id], + needs: NEEDED_BY[integration.id], })); /** The catalog's integration: for a custom MCP server, the entry with no actions, since those are a connection's own. */ @@ -51,7 +72,8 @@ export function integrationOf(connection: { // The integrations connected by signing in to them. Their sign-in starts at // `/api/connections//start`: the api sends the browser on to the service, and back to the new -// connection, or to the catalog with `?error=` when it didn't finish. Started with +// connection, or to the catalog with `?error=` when it didn't finish, or with +// `?error=-unconfigured` when the server lacks what it needs. Started with // `?returnTo=welcome`, it comes back to the welcome flow's page for the connection, or its catalog; // with `?account=`, as signing in again from a connection does, the service opens on that account // where it can. @@ -72,6 +94,12 @@ export function signInPath( return `/api/connections/${integrationId}/start${search}`; } +/** What the server lacked when a sign-in came back with `?error=-unconfigured`. */ +export function unmetNeed(error: string | null): Need | null { + const integrationId = error?.match(/^(.+)-unconfigured$/)?.[1]; + return findIntegration(integrationId ?? '')?.needs ?? null; +} + /** What the catalog says when a sign-in came back with `?error=`. */ export function signInFailedMessage(integrationId: string | null): string | null { const failed = findIntegration(integrationId ?? ''); diff --git a/packages/web/src/loaders.ts b/packages/web/src/loaders.ts index 3a2e1c8e..40875227 100644 --- a/packages/web/src/loaders.ts +++ b/packages/web/src/loaders.ts @@ -1,3 +1,4 @@ +import {getSignInMethods} from '@/client/auth-client'; import {getAgent, listActivity, listAgents, listMcpClients} from '@/client/agents-client'; import {getConnection, listConnections} from '@/client/connections-client'; import {getMe} from '@/client/me-client'; @@ -18,9 +19,15 @@ export async function connectionsLoader() { }; } -// The catalog, with how many accounts of each integration are connected. +// The catalog, with how many accounts of each integration are connected, and whether the server +// has what each needs (`/auth/methods`); an api that doesn't answer leaves every integration on +// offer, and a sign-in that can't start says what is missing. export async function addConnectionLoader() { - return {connections: unwrapLoaderResult(await listConnections()).connections}; + const [connections, methods] = await Promise.all([listConnections(), getSignInMethods()]); + return { + connections: unwrapLoaderResult(connections).connections, + configured: methods.map(({needs}) => needs).unwrapOr({google: true}), + }; } export async function connectionLoader({params}: {params: {id?: string}}) { @@ -125,15 +132,22 @@ export async function connectAgentLoader({request}: {request: Request}) { export type ConnectAgentData = Awaited>; // The welcome flow, shown until the organization finishes or skips it: what the person has -// connected so far, so each step can say what is done. Once finished, the app itself. +// connected so far, so each step can say what is done, and what the server has set up for the +// catalog. Once finished, the app itself. export async function welcomeLoader() { - const [me, agents, connections] = await Promise.all([getMe(), listAgents(), listConnections()]); + const [me, agents, connections, methods] = await Promise.all([ + getMe(), + listAgents(), + listConnections(), + getSignInMethods(), + ]); if (unwrapLoaderResult(me).onboarded) { throw redirect('/connections'); } return { agents: unwrapLoaderResult(agents).agents, connections: unwrapLoaderResult(connections).connections, + configured: methods.map(({needs}) => needs).unwrapOr({google: true}), }; } diff --git a/packages/web/src/pages/add-connection-page.tsx b/packages/web/src/pages/add-connection-page.tsx index 1641105c..a9e9729f 100644 --- a/packages/web/src/pages/add-connection-page.tsx +++ b/packages/web/src/pages/add-connection-page.tsx @@ -1,18 +1,16 @@ import {useLoaderData, useNavigate, useSearchParams} from 'react-router'; import {AppShell, PageTitle} from '@/components/app-shell'; import {BackButton} from '@/components/back-button'; -import {IntegrationCatalog} from '@/components/integration-catalog'; -import {signInFailedMessage} from '@/lib/integrations'; +import {IntegrationCatalog, SignInFailed} from '@/components/integration-catalog'; import type {addConnectionLoader} from '@/loaders'; // The catalog: a plus per integration, with how many accounts of it are connected. A mailbox, a // database or a custom MCP server goes to its form; a sign-in starts at the api. Each lands on the // new connection's page. export function AddConnectionPage() { - const {connections} = useLoaderData(); + const {connections, configured} = useLoaderData(); const navigate = useNavigate(); const [params] = useSearchParams(); - const failed = signInFailedMessage(params.get('error')); return ( } > - {failed &&

    {failed}

    } + navigate(`/connections/new/${integrationId}`)} />
    diff --git a/packages/web/src/pages/welcome-page.tsx b/packages/web/src/pages/welcome-page.tsx index c13d2f11..8b3a89ac 100644 --- a/packages/web/src/pages/welcome-page.tsx +++ b/packages/web/src/pages/welcome-page.tsx @@ -9,7 +9,7 @@ import {ConnectDatabaseForm} from '@/components/connect-database-form'; import {ConnectEmailForm} from '@/components/connect-email-form'; import {ConnectMcpForm} from '@/components/connect-mcp-form'; import {ConnectionAccess, SignInAgain} from '@/components/connection-access'; -import {IntegrationCatalog} from '@/components/integration-catalog'; +import {IntegrationCatalog, SignInFailed} from '@/components/integration-catalog'; import {StepHeading} from '@/components/step-heading'; import {SuccessLine} from '@/components/success-line'; import {Button} from '@proxy/ui/components/button'; @@ -17,8 +17,8 @@ import {defaultActions, defaultsOnOrOff, integrationOf} from '@/lib/access'; import { describeDatabase, EMAIL_FORM, - signInFailedMessage, signInPath, + type Configured, type Integration, } from '@/lib/integrations'; import {describeFetchError} from '@/lib/loader-utils'; @@ -34,7 +34,7 @@ type Step = 'agent' | 'connections' | 'email' | 'mcp' | DatabaseDialect; // services, each connection's permissions confirmed as it is made. Done, or skipped, it is not // shown again. export function WelcomePage({step}: {step: Step}) { - const {agents, connections} = useLoaderData(); + const {agents, connections, configured} = useLoaderData(); const navigate = useNavigate(); const [finishing, setFinishing] = useState(false); const [error, setError] = useState(null); @@ -65,6 +65,7 @@ export function WelcomePage({step}: {step: Step}) { void finish()} /> @@ -148,17 +149,18 @@ function Frame({step, children}: {step: 1 | 2; children: ReactNode}) { function ConnectionsStep({ agentName, connections, + configured, finishing, onDone, }: { agentName: string; connections: Connection[]; + configured: Configured; finishing: boolean; onDone: () => void; }) { const navigate = useNavigate(); const [params] = useSearchParams(); - const failed = signInFailedMessage(params.get('error')); const connected = connections.filter((connection) => connection.integrationId !== 'info'); return ( @@ -167,9 +169,10 @@ function ConnectionsStep({ Add an account, then choose what {agentName} may do with it. The rest can be connected later. - {failed &&

    {failed}

    } + navigate(`/welcome/connections/${integrationId}`)} /> diff --git a/packages/web/src/router.tsx b/packages/web/src/router.tsx index 2d880d90..ce10f472 100644 --- a/packages/web/src/router.tsx +++ b/packages/web/src/router.tsx @@ -57,7 +57,7 @@ async function humanLoader() { async function loginLoader({request}: {request: Request}): Promise { const [me, methods] = await Promise.all([getMe(), getSignInMethods()]); if (me.isErr()) { - return methods.unwrapOr({google: true}); + return methods.unwrapOr({google: true, needs: {google: true}}); } const callbackUrl = sanitizeCallbackUrl(new URL(request.url).searchParams.get('callbackUrl'));