diff --git a/AGENTS.md b/AGENTS.md index e002d18..ba0767d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,9 @@ 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, Granola's meeting notes, Notion's pages and Linear's issues over their MCP servers) 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. Integrations join the catalog once they work end to end; Google Workspace comes later. 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). 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. 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). 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. -- **Human side** behind `/login`: `/connections`, `/connections/new` (connects a mailbox with an app password, or Granola, Notion and Linear by signing in to them), `/connections/:id` (the defaults under Permissions, with how many agents differ per action, and a "Connection added successfully" when `?added` says it was just connected), `/info`, `/agents` (chooses an agent company that does not have a login yet), `/agents/:id` (the password shows once, after creating or resetting; 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). 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, ChatGPT) adds `/mcp` as a custom connector and signs in; the person picks which agent login it works as on `/connect-agent`. 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`). +- **Accounts**: a human signs in with Google or a magic link (`src/auth/` in the api). 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 picks the agent from `AGENT_PROVIDERS` and does what its `connects` says, an `mcp` agent (Claude, Claude Code, Claude Desktop, ChatGPT, Poke) shown the server address and that agent's steps (Claude Code's a terminal command) 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) 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; 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. Organizations from before the flow were marked done by its migration. +- **Human side** behind `/login`: `/connections`, `/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, and Granola, Notion and Linear are connected by signing in to them; `ConnectEmailForm` and `IntegrationCatalog` serve the welcome flow too), `/connections/:id` (the defaults under Permissions, with how many agents differ per action, and a "Connection added successfully" when `?added` says it was just connected), `/info`, `/agents` (chooses an agent company that does not have a login yet), `/agents/:id` (the password shows once, after creating or resetting; 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). 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, ChatGPT) adds `/mcp` as a custom connector and signs in; the person picks which agent login it works as on `/connect-agent`. 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/: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, and Send beside Save as draft on a new email (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/docs/assets/agents/claude-code.png b/docs/assets/agents/claude-code.png new file mode 100644 index 0000000..ceab6f0 Binary files /dev/null and b/docs/assets/agents/claude-code.png differ diff --git a/docs/assets/agents/claude-desktop.png b/docs/assets/agents/claude-desktop.png new file mode 100644 index 0000000..ceab6f0 Binary files /dev/null and b/docs/assets/agents/claude-desktop.png differ diff --git a/packages/api/src/connections/email/route.test.ts b/packages/api/src/connections/email/route.test.ts index 7589d56..88338c5 100644 --- a/packages/api/src/connections/email/route.test.ts +++ b/packages/api/src/connections/email/route.test.ts @@ -115,3 +115,32 @@ describe('handleConnectEmailRoute', () => { expect(createConnection).not.toHaveBeenCalled(); }); }); + +describe('handleTestEmailRoute', () => { + test('signs in to the mailbox and saves nothing', async () => { + const {handleTestEmailRoute} = await import('./route'); + const result = await handleTestEmailRoute(makeRequest({provider: 'gmail', email: ' Alex@Gmail.com ', password: 'abcd efgh ijkl mnop'})); + + expect(result.isOk()).toBe(true); + expect(checkMailbox).toHaveBeenCalledWith({imapHost: 'imap.gmail.com', smtpHost: 'smtp.gmail.com', smtpPort: 465, username: 'alex@gmail.com', password: 'abcdefghijklmnop'}); + expect(createConnection).not.toHaveBeenCalled(); + expect(encryptSecret).not.toHaveBeenCalled(); + }); + + test("reports the mailbox's refusal", async () => { + checkMailbox.mockResolvedValue(Err(ApiErr.credentialsRejected())); + + const {handleTestEmailRoute} = await import('./route'); + const result = await handleTestEmailRoute(makeRequest({provider: 'gmail', email: 'alex@gmail.com', password: 'wrong'})); + + expect(result.unwrapErr().kind).toBe('credentials_rejected'); + }); + + test('another provider needs its servers', async () => { + const {handleTestEmailRoute} = await import('./route'); + const result = await handleTestEmailRoute(makeRequest({provider: 'other', email: 'alex@example.com', password: 'secret'})); + + expect(result.isErr()).toBe(true); + expect(checkMailbox).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/connections/email/route.ts b/packages/api/src/connections/email/route.ts index 565f7c3..7b9d1b5 100644 --- a/packages/api/src/connections/email/route.ts +++ b/packages/api/src/connections/email/route.ts @@ -1,7 +1,7 @@ import {Err, Ok, type Result} from 'ts-results-es'; import {z} from 'zod'; -import {ApiErr, type ApiError, Do, parseSchema} from '@proxy/utils'; +import {ApiErr, type ApiError, Do, parseSchema, requirePresent} from '@proxy/utils'; import {createConnection, listConnections} from '@proxy/db/connection'; import {EMAIL_PROVIDERS, type EmailProvider, type MailServers} from '@proxy/integrations'; @@ -46,6 +46,30 @@ function passwordFor(provider: EmailProvider, password: string): string { return password.replace(/\s/g, ''); } +// The credential a request describes: the provider's servers or the typed-in ones, and the +// password as the server takes it. +function credentialFor(body: unknown): Result<{email: string; credential: EmailCredential}, ApiError> { + return Do<{email: string; credential: EmailCredential}, ApiError>(($) => { + const parsed = $(parseSchema(connectEmailBodySchema, body)); + const provider = $( + requirePresent( + EMAIL_PROVIDERS.find((candidate) => candidate.id === parsed.provider), + ApiErr.validationError('Unknown provider'), + ), + ); + const servers = $(serversFor(provider, parsed)); + return {email: parsed.email, credential: {...servers, username: parsed.email, password: passwordFor(provider, parsed.password)}}; + }); +} + +/** Signs in to the mailbox over IMAP and SMTP and saves nothing: the Test connection button. */ +export function handleTestEmailRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const {credential} = $(credentialFor(request.body)); + $(await checkMailbox(credential)); + }); +} + /** * Connects a mailbox with an app password. Signs in over IMAP and SMTP first, so a wrong password * or server is reported now rather than on an agent's first request. A new connection gives agents @@ -53,23 +77,18 @@ function passwordFor(provider: EmailProvider, password: string): string { */ export function handleConnectEmailRoute(request: AuthenticatedRequest): Promise> { return Do(async ($) => { - const body = $(parseSchema(connectEmailBodySchema, request.body)); - const provider = EMAIL_PROVIDERS.find((candidate) => candidate.id === body.provider); - if (!provider) { - return $(Err(ApiErr.validationError('Unknown provider'))); - } + const {email, credential} = $(credentialFor(request.body)); const orgId = $(await requireUserOrgId(request)); const existing = $(await listConnections(orgId)); - if (existing.some((connection) => connection.integrationId === 'email' && connection.account === body.email)) { + if (existing.some((connection) => connection.integrationId === 'email' && connection.account === email)) { return $(Err(ApiErr.conflict('This mailbox is already connected'))); } - const credential: EmailCredential = {...$(serversFor(provider, body)), username: body.email, password: passwordFor(provider, body.password)}; const encrypted = $(encryptSecret(JSON.stringify(credential))); $(await checkMailbox(credential)); - const row = $(await createConnection({orgId, integrationId: 'email', account: body.email, credential: encrypted})); + const row = $(await createConnection({orgId, integrationId: 'email', account: email, credential: encrypted})); return toConnectionResponse(row); }); } diff --git a/packages/api/src/connections/granola/route.test.ts b/packages/api/src/connections/granola/route.test.ts index 57bb787..febf68b 100644 --- a/packages/api/src/connections/granola/route.test.ts +++ b/packages/api/src/connections/granola/route.test.ts @@ -63,10 +63,10 @@ function makeRes() { const user = {userId: 'user-1', email: 'alex@example.com'}; // The start route for a real state cookie and Granola sign-in address. -async function start() { +async function start(query: Record = {}) { const {handleStartGranolaRoute} = await import('./route'); const res = makeRes(); - await handleStartGranolaRoute({user, query: {}, headers: {}} as never, res as never); + await handleStartGranolaRoute({user, query, headers: {}} as never, res as never); const cookie = res.cookies.find((candidate) => candidate.name === 'proxy.granola-state'); const authorizeUrl = new URL(res.redirect.mock.calls[0]?.[0] as string); return {res, cookie, authorizeUrl, state: authorizeUrl.searchParams.get('state') ?? ''}; @@ -151,4 +151,22 @@ describe('handleGranolaCallbackRoute', () => { expect(res.redirect).toHaveBeenCalledWith('https://app.example.com/connections/new?error=granola'); expect(createConnection).not.toHaveBeenCalled(); }); + + test('a sign-in started from the welcome flow ends on its page for the connection, or back on its catalog', async () => { + const done = await start({returnTo: 'welcome'}); + const finished = await callback({cookie: done.cookie?.value ?? '', query: {state: done.state, code: 'code-1'}}); + expect(createConnection).toHaveBeenCalledTimes(1); + expect(finished.redirect).toHaveBeenCalledWith('https://app.example.com/welcome/connections/conn-1'); + + const turnedDown = await start({returnTo: 'welcome'}); + const failed = await callback({cookie: turnedDown.cookie?.value ?? '', query: {state: turnedDown.state, error: 'access_denied'}}); + expect(failed.redirect).toHaveBeenCalledWith('https://app.example.com/welcome/connections?error=granola'); + }); + + test('an unknown returnTo is ignored', async () => { + const {cookie, state} = await start({returnTo: 'https://evil.example.com'}); + const res = await callback({cookie: cookie?.value ?? '', query: {state, code: 'code-1'}}); + + expect(res.redirect).toHaveBeenCalledWith('https://app.example.com/connections/conn-1?added'); + }); }); diff --git a/packages/api/src/connections/mcp/mcp-connect.ts b/packages/api/src/connections/mcp/mcp-connect.ts index 6c7fcc6..1d0bb70 100644 --- a/packages/api/src/connections/mcp/mcp-connect.ts +++ b/packages/api/src/connections/mcp/mcp-connect.ts @@ -22,12 +22,15 @@ import {exchangeMcpCode, makePkce, mcpAuthorizeUrl, registerMcpClient, type McpS * Personal Agent Proxy with the server and sends the browser to its sign-in, keeping the handshake * in a short-lived encrypted cookie; the callback trades the code for tokens and makes the * connection, or gives an existing one of the same account its new tokens. Either ends on the - * connection's page, or back on the catalog with `?error=`. + * connection's page, or back on the catalog with `?error=`; started from the welcome + * flow (`?returnTo=welcome`), they end on its page for the connection, or its catalog, instead. */ const stateCookieMaxAgeMs = 15 * 60 * 1000; -const statePayloadSchema = z.object({state: z.string(), verifier: z.string(), clientId: z.string(), userId: z.string()}); +const returnToSchema = z.enum(['welcome']); + +const statePayloadSchema = z.object({state: z.string(), verifier: z.string(), clientId: z.string(), userId: z.string(), returnTo: returnToSchema.optional()}); type StatePayload = z.infer; @@ -65,16 +68,23 @@ export function mcpConnectRoutes(args: { }); } - function redirectToConnectError(res: Response, appUrl: string, error: ApiError): void { + // Where the sign-in ends: the catalog, or the welcome flow's connections step it started from. + function catalogUrl(appUrl: string, returnTo: StatePayload['returnTo']): string { + if (returnTo === 'welcome') { + return `${appUrl}/welcome/connections`; + } + return `${appUrl}/connections/new`; + } + + function redirectToConnectError(res: Response, appUrl: string, returnTo: StatePayload['returnTo'], error: ApiError): void { log.warn(`Connecting ${server.name} failed`, {kind: error.kind, ...('message' in error ? {message: error.message} : {}), ...('cause' in error ? serializeError(error.cause) : {})}); - res.redirect(`${appUrl}/connections/new?error=${integrationId}`); + res.redirect(`${catalogUrl(appUrl, returnTo)}?error=${integrationId}`); } // The handshake the cookie holds, checked against the server's answer, then the connection made // or given its new tokens. - function finishConnect(request: AuthenticatedRequest, appUrl: string): Promise> { + function finishConnect(request: AuthenticatedRequest, appUrl: string, payload: StatePayload): Promise> { return Do(async ($) => { - const payload = $(readStateCookie(request)); const state = queryParam(request, 'state') ?? ''; if (!timingSafeEqualString(state, payload.state) || payload.userId !== request.user.userId) { return $(Err(ApiErr.validationError(`${server.name} sign-in state does not match`))); @@ -109,15 +119,16 @@ export function mcpConnectRoutes(args: { return; } + const returnTo = returnToSchema.safeParse(queryParam(request, 'returnTo')).data; const started = await Do<{cookie: string; url: string}, ApiError>(async ($) => { const clientId = $(await registerMcpClient(server, redirectUri(appUrl))); const state = randomBytes(16).toString('hex'); const {verifier, challenge} = makePkce(); - const cookie = $(encryptSecret(JSON.stringify({state, verifier, clientId, userId: request.user.userId} satisfies StatePayload))); + const cookie = $(encryptSecret(JSON.stringify({state, verifier, clientId, userId: request.user.userId, ...(returnTo ? {returnTo} : {})} satisfies StatePayload))); return {cookie, url: mcpAuthorizeUrl(server, {clientId, redirectUri: redirectUri(appUrl), state, challenge})}; }); if (started.isErr()) { - redirectToConnectError(res, appUrl, started.error); + redirectToConnectError(res, appUrl, returnTo, started.error); return; } @@ -125,7 +136,10 @@ export function mcpConnectRoutes(args: { res.redirect(started.value.url); } - /** `GET /api/connections//callback` — where the sign-in comes back to; lands on the connection, saying it was added. */ + /** + * `GET /api/connections//callback` — where the sign-in comes back to; lands on the + * connection, saying it was added, or on the welcome flow's page for it when it started there. + */ async function handleCallback(request: AuthenticatedRequest, res: Response): Promise { const appUrl = env.APP_URL; if (!appUrl) { @@ -135,9 +149,15 @@ export function mcpConnectRoutes(args: { // The handshake is single-use: cleared however the callback ends. res.clearCookie(stateCookieName, stateCookieOptions()); - const connectionId = await finishConnect(request, appUrl); + const payload = readStateCookie(request); + const returnTo = payload.isOk() ? payload.value.returnTo : undefined; + const connectionId = payload.isErr() ? Err(payload.error) : await finishConnect(request, appUrl, payload.value); if (connectionId.isErr()) { - redirectToConnectError(res, appUrl, connectionId.error); + redirectToConnectError(res, appUrl, returnTo, connectionId.error); + return; + } + if (returnTo === 'welcome') { + res.redirect(`${appUrl}/welcome/connections/${connectionId.value}`); return; } res.redirect(`${appUrl}/connections/${connectionId.value}?added`); diff --git a/packages/api/src/me/route.test.ts b/packages/api/src/me/route.test.ts index a796d41..504488a 100644 --- a/packages/api/src/me/route.test.ts +++ b/packages/api/src/me/route.test.ts @@ -4,11 +4,18 @@ import {Err, Ok} from 'ts-results-es'; import {ApiErr} from '@proxy/utils'; const getUserFromId = mock(); +const getUserOrganization = mock(); +const markOrganizationOnboarded = mock(); mock.module('@proxy/db/user', () => ({ getUserFromId, })); +mock.module('@proxy/db/organization', () => ({ + getUserOrganization, + markOrganizationOnboarded, +})); + const user = { id: 'user-1', email: 'test@example.com', @@ -26,10 +33,12 @@ function makeRequest() { beforeEach(() => { mock.clearAllMocks(); getUserFromId.mockResolvedValue(Ok(user)); + getUserOrganization.mockResolvedValue(Ok({id: 'org-1', onboardedAt: new Date('2026-02-01T00:00:00Z')})); + markOrganizationOnboarded.mockResolvedValue(Ok(undefined)); }); describe('handleMeRoute', () => { - test('returns the signed-in user, only the public fields', async () => { + test('returns the signed-in user, only the public fields, and that the welcome flow is done', async () => { const {handleMeRoute} = await import('./route'); const result = await handleMeRoute(makeRequest() as never); @@ -41,8 +50,19 @@ describe('handleMeRoute', () => { name: 'Test User', image: 'https://example.com/avatar.png', }, + onboarded: true, }); expect(getUserFromId).toHaveBeenCalledWith('user-1'); + expect(getUserOrganization).toHaveBeenCalledWith('user-1'); + }); + + test('a new organization is not onboarded', async () => { + getUserOrganization.mockResolvedValue(Ok({id: 'org-1', onboardedAt: null})); + + const {handleMeRoute} = await import('./route'); + const result = await handleMeRoute(makeRequest() as never); + + expect(result.unwrap().onboarded).toBe(false); }); test('returns unauthenticated when the session user no longer exists', async () => { @@ -55,6 +75,15 @@ describe('handleMeRoute', () => { expect(result.unwrapErr().kind).toBe('unauthenticated'); }); + test('returns unauthenticated when the user has no organization', async () => { + getUserOrganization.mockResolvedValue(Ok(null)); + + const {handleMeRoute} = await import('./route'); + const result = await handleMeRoute(makeRequest() as never); + + expect(result.unwrapErr().kind).toBe('unauthenticated'); + }); + test('propagates db errors', async () => { getUserFromId.mockResolvedValue(Err(ApiErr.dbError(new Error('boom')))); @@ -65,3 +94,23 @@ describe('handleMeRoute', () => { expect(result.unwrapErr().kind).toBe('db_error'); }); }); + +describe('handleFinishOnboardingRoute', () => { + test("marks the user's organization onboarded", async () => { + const {handleFinishOnboardingRoute} = await import('./route'); + const result = await handleFinishOnboardingRoute(makeRequest() as never); + + expect(result.isOk()).toBe(true); + expect(markOrganizationOnboarded).toHaveBeenCalledWith('org-1'); + }); + + test('returns unauthenticated when the user has no organization', async () => { + getUserOrganization.mockResolvedValue(Ok(null)); + + const {handleFinishOnboardingRoute} = await import('./route'); + const result = await handleFinishOnboardingRoute(makeRequest() as never); + + expect(result.unwrapErr().kind).toBe('unauthenticated'); + expect(markOrganizationOnboarded).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/api/src/me/route.ts b/packages/api/src/me/route.ts index 2d77948..fc18afd 100644 --- a/packages/api/src/me/route.ts +++ b/packages/api/src/me/route.ts @@ -1,12 +1,15 @@ import {Err, type Result} from 'ts-results-es'; -import {ApiErr, type ApiError, Do} from '@proxy/utils'; +import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils'; +import {getUserOrganization, markOrganizationOnboarded} from '@proxy/db/organization'; import {getUserFromId} from '@proxy/db/user'; import type {AuthenticatedRequest} from '../server/middleware/require-auth'; export type MeResponse = { user: {id: string; email: string; name: string | null; image: string | null}; + /** False until the welcome flow is finished or skipped; the web app shows it instead of the app until then. */ + onboarded: boolean; }; /** Who is signed in. The web app's guard: 401 means "send them to /login". */ @@ -17,9 +20,19 @@ export function handleMeRoute(request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const organization = $(requirePresent($(await getUserOrganization(request.user.userId)), ApiErr.unauthenticated())); + $(await markOrganizationOnboarded(organization.id)); + }); +} diff --git a/packages/api/src/server/create-api-router.ts b/packages/api/src/server/create-api-router.ts index 2620b0d..f9660ca 100644 --- a/packages/api/src/server/create-api-router.ts +++ b/packages/api/src/server/create-api-router.ts @@ -18,13 +18,13 @@ import {handleDevLoginRoute} from '../auth/dev-login'; import {handleEmailSignInRoute, handleEmailVerifyRoute} from '../auth/email'; import {handleGoogleCallbackRoute, handleGoogleStartRoute, handleSignInMethodsRoute} from '../auth/google'; import {handleSignOutRoute} from '../auth/signout'; -import {handleConnectEmailRoute} from '../connections/email/route'; +import {handleConnectEmailRoute, handleTestEmailRoute} from '../connections/email/route'; import {handleGranolaCallbackRoute, handleStartGranolaRoute} from '../connections/granola/route'; import {handleLinearCallbackRoute, handleStartLinearRoute} from '../connections/linear/route'; import {handleNotionCallbackRoute, handleStartNotionRoute} from '../connections/notion/route'; import {handleDeleteConnectionRoute, handleGetConnectionRoute, handleListConnectionsRoute, handleSetConnectionDefaultsRoute} from '../connections/route'; import {handleMcpMethodNotAllowedRoute, handleMcpRoute} from '../mcp/route'; -import {handleMeRoute} from '../me/route'; +import {handleFinishOnboardingRoute, handleMeRoute} from '../me/route'; import {handleAuthorizeRoute, handleConsentRoute, handleGetAuthorizationRequestRoute} from '../oauth/authorize'; import {handleAuthorizationServerMetadataRoute, handleProtectedResourceMetadataRoute} from '../oauth/metadata'; import {handleRegisterClientRoute} from '../oauth/register'; @@ -55,10 +55,12 @@ export function createApiRouter(): express.Router { router.use('/api/me', requireBrowserOrigin); router.get('/api/me', withAuthResult('Me route', handleMeRoute)); + router.post('/api/me/onboarded', withAuthResult('Finish onboarding route', handleFinishOnboardingRoute)); router.use('/api/connections', requireBrowserOrigin); router.get('/api/connections', withAuthResult('List connections route', handleListConnectionsRoute)); router.post('/api/connections/email', withAuthResult('Connect email route', handleConnectEmailRoute)); + router.post('/api/connections/email/test', withAuthResult('Test email route', handleTestEmailRoute)); router.get('/api/connections/granola/start', withAuth('Start Granola sign-in route', handleStartGranolaRoute)); router.get('/api/connections/granola/callback', withAuth('Granola sign-in callback route', handleGranolaCallbackRoute)); router.get('/api/connections/notion/start', withAuth('Start Notion sign-in route', handleStartNotionRoute)); diff --git a/packages/db/prisma/migrations/v012_add_organization_onboarded/migration.sql b/packages/db/prisma/migrations/v012_add_organization_onboarded/migration.sql new file mode 100644 index 0000000..728bfa0 --- /dev/null +++ b/packages/db/prisma/migrations/v012_add_organization_onboarded/migration.sql @@ -0,0 +1,5 @@ +-- When the organization finished or skipped the welcome flow. Organizations from before it +-- count as done, so nobody who already set things up is sent through it. +ALTER TABLE "Organization" ADD COLUMN "onboardedAt" TIMESTAMP(3); + +UPDATE "Organization" SET "onboardedAt" = CURRENT_TIMESTAMP; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8d046b1..9b5ab82 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -92,6 +92,8 @@ model Organization { id String @id @default(cuid()) name String slug String @unique + // When the welcome flow was finished or skipped; until then the web app shows it instead of the app. + onboardedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt diff --git a/packages/db/scripts/seed.ts b/packages/db/scripts/seed.ts index 84fef6f..a33d542 100644 --- a/packages/db/scripts/seed.ts +++ b/packages/db/scripts/seed.ts @@ -83,7 +83,7 @@ const user = await db.user.create({ data: {email: DEMO_EMAIL, name: 'Demo User', emailVerified: new Date(), createdAt: daysAgo(40)}, }); const org = await db.organization.create({ - data: {name: ORG_NAME, slug: 'demo-workspace', createdAt: daysAgo(40)}, + data: {name: ORG_NAME, slug: 'demo-workspace', onboardedAt: daysAgo(40), createdAt: daysAgo(40)}, }); await db.orgMember.create({data: {orgId: org.id, userId: user.id}}); diff --git a/packages/db/src/organization.ts b/packages/db/src/organization.ts index b6bc049..abc037a 100644 --- a/packages/db/src/organization.ts +++ b/packages/db/src/organization.ts @@ -92,3 +92,32 @@ export async function getUserOrgId(userId: string): Promise member?.orgId ?? null); } + +export type UserOrganization = {id: string; onboardedAt: Date | null}; + +/** The user's organization with whether it has been through the welcome flow; `Ok(null)` means the user is gone. */ +export async function getUserOrganization( + userId: string, +): Promise> { + return ( + await wrapDb(() => + db.orgMember.findFirst({ + where: {userId}, + select: {org: {select: {id: true, onboardedAt: true}}}, + orderBy: {createdAt: 'asc'}, + }), + ) + ).map((member) => member?.org ?? null); +} + +/** Marks the welcome flow done, once: a second finish keeps the first time. */ +export async function markOrganizationOnboarded(orgId: string): Promise> { + return ( + await wrapDb(() => + db.organization.updateMany({ + where: {id: orgId, onboardedAt: null}, + data: {onboardedAt: new Date()}, + }), + ) + ).map(() => undefined); +} diff --git a/packages/integrations/src/agent-providers.test.ts b/packages/integrations/src/agent-providers.test.ts index b9012e7..8e2135a 100644 --- a/packages/integrations/src/agent-providers.test.ts +++ b/packages/integrations/src/agent-providers.test.ts @@ -37,6 +37,8 @@ describe('agent providers', () => { test("suggests the provider an MCP client's name points at", () => { expect(suggestAgentProvider('Claude')?.id).toBe('claude'); + expect(suggestAgentProvider('Claude Code')?.id).toBe('claude-code'); + expect(suggestAgentProvider('Claude Desktop')?.id).toBe('claude-desktop'); expect(suggestAgentProvider('ChatGPT Connector')?.id).toBe('chatgpt'); expect(suggestAgentProvider('Dot')?.id).toBe('dot'); expect(suggestAgentProvider('Poke MCP client')?.id).toBe('poke'); diff --git a/packages/integrations/src/agent-providers.ts b/packages/integrations/src/agent-providers.ts index 9d5e358..9e6e884 100644 --- a/packages/integrations/src/agent-providers.ts +++ b/packages/integrations/src/agent-providers.ts @@ -1,11 +1,29 @@ export type AgentProviderId = - 'grok-bot' | 'muse' | 'instinct' | 'dot' | 'poke' | 'claude' | 'chatgpt'; + | 'grok-bot' + | 'muse' + | 'instinct' + | 'dot' + | 'poke' + | 'claude' + | 'claude-code' + | 'claude-desktop' + | 'chatgpt'; export type AgentProvider = { id: AgentProviderId; name: string; aliases?: string[]; company: string; + /** + * How the agent gets its access: `mcp` adds Personal Agent Proxy as a connector and signs in + * over OAuth, which makes its login; `login` is handed a username and password. + */ + connects: 'mcp' | 'login'; + /** + * Runs on the person's own computer, so it reaches a Personal Agent Proxy there, such as one + * in development at `http://localhost`. Every other agent needs a public https address. + */ + local: boolean; /** Bundled with the web app, under `packages/web/public`. */ faviconUrl: string; }; @@ -16,13 +34,24 @@ export const AGENT_PROVIDERS: AgentProvider[] = [ id: 'grok-bot', name: 'Grok Bot', company: 'SpaceXAI', + connects: 'login', + local: false, faviconUrl: '/agent-providers/grok-bot.png', }, - {id: 'muse', name: 'Muse', company: 'Meta', faviconUrl: '/agent-providers/muse.png'}, + { + id: 'muse', + name: 'Muse', + company: 'Meta', + connects: 'login', + local: false, + faviconUrl: '/agent-providers/muse.png', + }, { id: 'instinct', name: 'Instinct', company: 'Instinct', + connects: 'login', + local: false, faviconUrl: '/agent-providers/instinct.png', }, { @@ -30,16 +59,50 @@ export const AGENT_PROVIDERS: AgentProvider[] = [ name: 'Dots', aliases: ['Dot'], company: 'OpenAI', + connects: 'login', + local: false, faviconUrl: '/agent-providers/dot.png', }, { id: 'poke', name: 'Poke', company: 'The Interaction Company', + connects: 'mcp', + local: false, faviconUrl: '/agent-providers/poke.png', }, - {id: 'claude', name: 'Claude', company: 'Anthropic', faviconUrl: '/agent-providers/claude.png'}, - {id: 'chatgpt', name: 'ChatGPT', company: 'OpenAI', faviconUrl: '/agent-providers/chatgpt.png'}, + { + id: 'claude', + name: 'Claude', + company: 'Anthropic', + connects: 'mcp', + local: false, + faviconUrl: '/agent-providers/claude.png', + }, + { + id: 'claude-code', + name: 'Claude Code', + company: 'Anthropic', + connects: 'mcp', + local: true, + faviconUrl: '/agent-providers/claude-code.png', + }, + { + id: 'claude-desktop', + name: 'Claude Desktop', + company: 'Anthropic', + connects: 'mcp', + local: true, + faviconUrl: '/agent-providers/claude-desktop.png', + }, + { + id: 'chatgpt', + name: 'ChatGPT', + company: 'OpenAI', + connects: 'mcp', + local: false, + faviconUrl: '/agent-providers/chatgpt.png', + }, ]; export function findAgentProvider(id: string): AgentProvider | undefined { @@ -48,13 +111,22 @@ export function findAgentProvider(id: string): AgentProvider | undefined { /** * The provider an MCP client's registered name points at, such as "Claude" or "ChatGPT", so - * connecting one suggests its login. Names are the client's own, so this only ever suggests. + * connecting one suggests its login. The longest name that fits wins, so "Claude Code" is Claude + * Code and not Claude. Names are the client's own, so this only ever suggests. */ export function suggestAgentProvider(clientName: string): AgentProvider | undefined { const name = clientName.toLowerCase(); - return AGENT_PROVIDERS.find( - (provider) => - name.includes(provider.name.toLowerCase()) || - provider.aliases?.some((alias) => name.includes(alias.toLowerCase())), - ); + let best: {provider: AgentProvider; length: number} | undefined; + for (const provider of AGENT_PROVIDERS) { + for (const candidate of [provider.name, ...(provider.aliases ?? [])]) { + if (!name.includes(candidate.toLowerCase())) { + continue; + } + if (best && best.length >= candidate.length) { + continue; + } + best = {provider, length: candidate.length}; + } + } + return best?.provider; } diff --git a/packages/integrations/src/catalog.test.ts b/packages/integrations/src/catalog.test.ts new file mode 100644 index 0000000..f64be70 --- /dev/null +++ b/packages/integrations/src/catalog.test.ts @@ -0,0 +1,18 @@ +import {describe, expect, test} from 'bun:test'; + +import {INTEGRATIONS} from './catalog'; + +describe('catalog', () => { + test('lists high-risk actions last in every integration, as every page shows them in this order', () => { + for (const integration of INTEGRATIONS) { + const firstHigh = integration.actions.findIndex((action) => action.risk === 'high'); + if (firstHigh === -1) { + continue; + } + const after = integration.actions.slice(firstHigh).map((action) => action.risk); + expect(after, `${integration.id} has a lower-risk action after a high-risk one`).toEqual( + after.map(() => 'high'), + ); + } + }); +}); diff --git a/packages/integrations/src/catalog.ts b/packages/integrations/src/catalog.ts index 6a6a966..74a508f 100644 --- a/packages/integrations/src/catalog.ts +++ b/packages/integrations/src/catalog.ts @@ -41,13 +41,6 @@ const email: Integration = { risk: 'medium', requires: 'read', }, - { - id: 'trash', - label: 'Move to Trash', - description: 'Move emails to Trash, where they can be restored from', - risk: 'high', - requires: 'read', - }, { id: 'write', label: 'Write drafts', @@ -55,6 +48,13 @@ const email: Integration = { risk: 'medium', requires: 'read', }, + { + id: 'trash', + label: 'Move to Trash', + description: 'Move emails to Trash, where they can be restored from', + risk: 'high', + requires: 'read', + }, { id: 'send', label: 'Send', diff --git a/packages/web/public/agent-providers/claude-code.png b/packages/web/public/agent-providers/claude-code.png new file mode 100644 index 0000000..ceab6f0 Binary files /dev/null and b/packages/web/public/agent-providers/claude-code.png differ diff --git a/packages/web/public/agent-providers/claude-desktop.png b/packages/web/public/agent-providers/claude-desktop.png new file mode 100644 index 0000000..ceab6f0 Binary files /dev/null and b/packages/web/public/agent-providers/claude-desktop.png differ diff --git a/packages/web/src/client/connections-client.ts b/packages/web/src/client/connections-client.ts index f2409c5..7ba01f8 100644 --- a/packages/web/src/client/connections-client.ts +++ b/packages/web/src/client/connections-client.ts @@ -33,3 +33,8 @@ export type ConnectEmailInput = { export function connectEmail(input: ConnectEmailInput) { return apiRequest('POST', '/api/connections/email', connectionSchema, input); } + +/** Signs in to the mailbox and saves nothing: the Test connection button. Answers 204, or the same errors as connecting. */ +export function testEmail(input: ConnectEmailInput) { + return apiSend('POST', '/api/connections/email/test', input); +} diff --git a/packages/web/src/client/me-client.ts b/packages/web/src/client/me-client.ts index 05f3e8d..ef78722 100644 --- a/packages/web/src/client/me-client.ts +++ b/packages/web/src/client/me-client.ts @@ -1,5 +1,6 @@ import {httpRequest, type FetchError} from '@proxy/utils'; import {z} from 'zod'; +import {apiSend} from '@/client/request'; const meSchema = z.object({ user: z.object({ @@ -8,6 +9,8 @@ const meSchema = z.object({ name: z.string().nullable(), image: z.string().nullable(), }), + // False until the welcome flow is finished or skipped; the human side shows it until then. + onboarded: z.boolean(), }); export type Me = z.infer; @@ -26,3 +29,8 @@ export function isNotFoundError(error: FetchError): boolean { export function getMe() { return httpRequest('/api/me', {method: 'GET'}, {schema: meSchema}); } + +/** The welcome flow is done, or skipped: the human side opens on the app from now on. */ +export function finishOnboarding() { + return apiSend('POST', '/api/me/onboarded'); +} diff --git a/packages/web/src/components/agent-provider-tile.tsx b/packages/web/src/components/agent-provider-tile.tsx new file mode 100644 index 0000000..115c2e8 --- /dev/null +++ b/packages/web/src/components/agent-provider-tile.tsx @@ -0,0 +1,44 @@ +import type {AgentProvider} from '@proxy/integrations'; +import {AgentLogo} from '@/components/brand-logo'; +import {cn} from '@proxy/ui/lib/utils'; + +// One agent to choose, in a grid of them: its logo, name and company, outlined when chosen. One +// that can't be chosen here is greyed out, its detail saying why. +export function AgentProviderTile({ + provider, + chosen, + disabled, + autoFocus, + detail, + onChoose, +}: { + provider: AgentProvider; + chosen: boolean; + disabled?: boolean; + autoFocus?: boolean; + detail?: string; + onChoose: () => void; +}) { + return ( + + ); +} diff --git a/packages/web/src/components/connect-email-form.tsx b/packages/web/src/components/connect-email-form.tsx new file mode 100644 index 0000000..a002528 --- /dev/null +++ b/packages/web/src/components/connect-email-form.tsx @@ -0,0 +1,236 @@ +import {EMAIL_PROVIDERS, type EmailProvider} from '@proxy/integrations'; +import type {FetchError} from '@proxy/utils'; +import {useState, type ReactNode} from 'react'; +import {connectEmail, testEmail, type ConnectEmailInput} from '@/client/connections-client'; +import {Button} from '@proxy/ui/components/button'; +import {Input} from '@proxy/ui/components/input'; +import {Label} from '@proxy/ui/components/label'; +import {SuccessLine} from '@/components/success-line'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@proxy/ui/components/select'; +import type {Connection} from '@/lib/types'; + +const PROVIDER_ITEMS = EMAIL_PROVIDERS.map((provider) => ({ + value: provider.id, + label: provider.name, +})); + +const SMTP_PORT_ITEMS = [ + {value: '465', label: '465 (TLS)'}, + {value: '587', label: '587 (STARTTLS)'}, +]; + +function isProviderId(value: string): value is EmailProvider['id'] { + return EMAIL_PROVIDERS.some((provider) => provider.id === value); +} + +function connectErrorMessage(error: FetchError): string { + if (error.kind === 'http' && error.status === 422) { + return 'The mailbox turned the password down. Check the address and the app password.'; + } + if (error.kind === 'http' && error.status === 502) { + return 'The mail server could not be reached. Check the servers and try again.'; + } + if (error.kind === 'http' && error.status === 409) { + return 'This mailbox is already connected.'; + } + if (error.kind === 'http' && error.status === 400) { + return 'Check the address and the servers.'; + } + return 'Something went wrong. Try again.'; +} + +// The mailbox form: provider, address and app password, and the servers where the provider has +// none built in. Test connection signs in to the mailbox and saves nothing; connecting signs in +// again and saves, so a wrong password shows here and not on an agent's first request. After a +// test that passed, the submit button reads `continueLabel`. `cancel` sits beside the buttons. +export function ConnectEmailForm({ + cancel, + continueLabel = 'Connect', + onConnected, +}: { + cancel?: ReactNode; + continueLabel?: string; + onConnected: (connection: Connection) => void; +}) { + const [providerId, setProviderId] = useState('gmail'); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [imapHost, setImapHost] = useState(''); + const [smtpHost, setSmtpHost] = useState(''); + const [smtpPort, setSmtpPort] = useState<'465' | '587'>('465'); + const [error, setError] = useState(null); + const [pending, setPending] = useState<'test' | 'connect' | null>(null); + // The values the last passed test signed in with; the test counts while they stay the same. + const [tested, setTested] = useState(null); + const provider = EMAIL_PROVIDERS.find((candidate) => candidate.id === providerId); + const typesServers = provider?.servers === null; + const ready = + email.trim() !== '' && + password !== '' && + (!typesServers || (imapHost.trim() !== '' && smtpHost.trim() !== '')); + const input: ConnectEmailInput = { + provider: providerId, + email, + password, + ...(typesServers + ? {servers: {imapHost, smtpHost, smtpPort: smtpPort === '465' ? 465 : 587}} + : {}), + }; + const passed = tested === JSON.stringify(input); + + const test = async () => { + setPending('test'); + setError(null); + const result = await testEmail(input); + setPending(null); + if (result.isErr()) { + setError(connectErrorMessage(result.error)); + return; + } + setTested(JSON.stringify(input)); + }; + + return ( +
{ + event.preventDefault(); + setPending('connect'); + setError(null); + const result = await connectEmail(input); + setPending(null); + if (result.isErr()) { + setError(connectErrorMessage(result.error)); + return; + } + onConnected(result.value); + }} + > +
+ + +
+
+ + setEmail(event.target.value)} + /> +
+
+ + setPassword(event.target.value)} + /> + {provider?.appPasswordUrl && ( +

+ + Make an app password + + {provider.id === 'gmail' && ', which needs 2-Step Verification on the Google account'}. +

+ )} +
+ {typesServers && ( +
+ + setImapHost(event.target.value)} + /> +
+ )} + {typesServers && ( +
+
+ + setSmtpHost(event.target.value)} + /> +
+
+ + +
+
+ )} + {error &&

{error}

} + {passed && Signed in to {email.trim()}} +
+ {cancel} + + +
+
+ ); +} diff --git a/packages/web/src/components/icon-button.tsx b/packages/web/src/components/icon-button.tsx index ed618f8..642655e 100644 --- a/packages/web/src/components/icon-button.tsx +++ b/packages/web/src/components/icon-button.tsx @@ -8,26 +8,48 @@ type Props = { children: ReactNode; disabled?: boolean; size?: 'xs' | 'sm'; -} & ({onClick: () => void; to?: never} | {to: string; onClick?: never}); +} & ( + | {onClick: () => void; to?: never; href?: never} + | {to: string; onClick?: never; href?: never} + // A full navigation, such as to an api route that sends the browser on to a sign-in. + | {href: string; onClick?: never; to?: never} +); // A header's icon action: a ghost icon button with its label in a tooltip. -export function IconButton({label, children, disabled = false, size = 'sm', onClick, to}: Props) { +export function IconButton({ + label, + children, + disabled = false, + size = 'sm', + onClick, + to, + href, +}: Props) { + const buttonSize = size === 'xs' ? 'icon-xs' : 'icon-sm'; const button = - to === undefined ? ( + to !== undefined ? ( - ) : ( - - )} - - ))} - - setConnecting(false)} /> + {failed &&

{failed}

} + navigate('/connections/new/email')} + /> ); } - -const PROVIDER_ITEMS = EMAIL_PROVIDERS.map((provider) => ({ - value: provider.id, - label: provider.name, -})); - -const SMTP_PORT_ITEMS = [ - {value: '465', label: '465 (TLS)'}, - {value: '587', label: '587 (STARTTLS)'}, -]; - -function isProviderId(value: string): value is EmailProvider['id'] { - return EMAIL_PROVIDERS.some((provider) => provider.id === value); -} - -function connectErrorMessage(error: FetchError): string { - if (error.kind === 'http' && error.status === 422) { - return 'The mailbox turned the password down. Check the address and the app password.'; - } - if (error.kind === 'http' && error.status === 502) { - return 'The mail server could not be reached. Check the servers and try again.'; - } - if (error.kind === 'http' && error.status === 409) { - return 'This mailbox is already connected.'; - } - if (error.kind === 'http' && error.status === 400) { - return 'Check the address and the servers.'; - } - return 'Something went wrong. Try again.'; -} - -// Signs in to the mailbox before anything is saved, so a wrong password shows here and not on an -// agent's first request. -function ConnectEmailDialog({open, onClose}: {open: boolean; onClose: () => void}) { - const navigate = useNavigate(); - const [providerId, setProviderId] = useState('gmail'); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [imapHost, setImapHost] = useState(''); - const [smtpHost, setSmtpHost] = useState(''); - const [smtpPort, setSmtpPort] = useState<'465' | '587'>('465'); - const [error, setError] = useState(null); - const [pending, setPending] = useState(false); - const provider = EMAIL_PROVIDERS.find((candidate) => candidate.id === providerId); - const typesServers = provider?.servers === null; - const ready = - email.trim() !== '' && - password !== '' && - (!typesServers || (imapHost.trim() !== '' && smtpHost.trim() !== '')); - - const close = () => { - setPassword(''); - setError(null); - onClose(); - }; - - return ( - { - if (next) { - return; - } - close(); - }} - > - -
{ - event.preventDefault(); - setPending(true); - setError(null); - const result = await connectEmail({ - provider: providerId, - email, - password, - ...(typesServers - ? {servers: {imapHost, smtpHost, smtpPort: smtpPort === '465' ? 465 : 587}} - : {}), - }); - setPending(false); - if (result.isErr()) { - setError(connectErrorMessage(result.error)); - return; - } - close(); - navigate(`/connections/${result.value.id}?added`); - }} - > - - Connect a mailbox - - Personal Agent Proxy signs in with an app password, made for Personal Agent Proxy - alone in your mail account. Revoking it there disconnects Personal Agent Proxy. - - -
- - -
-
- - setEmail(event.target.value)} - /> -
-
- - setPassword(event.target.value)} - /> - {provider?.appPasswordUrl && ( -

- - Make an app password - - {provider.id === 'gmail' && - ', which needs 2-Step Verification on the Google account'} - . -

- )} -
- {typesServers && ( -
- - setImapHost(event.target.value)} - /> -
- )} - {typesServers && ( -
-
- - setSmtpHost(event.target.value)} - /> -
-
- - -
-
- )} - {error &&

{error}

} - - - - -
- - - ); -} diff --git a/packages/web/src/pages/add-email-page.tsx b/packages/web/src/pages/add-email-page.tsx new file mode 100644 index 0000000..0915f43 --- /dev/null +++ b/packages/web/src/pages/add-email-page.tsx @@ -0,0 +1,31 @@ +import {useNavigate} from 'react-router'; +import {AppShell, PageTitle} from '@/components/app-shell'; +import {BackButton} from '@/components/back-button'; +import {ConnectEmailForm} from '@/components/connect-email-form'; + +// The mailbox form, from the catalog's plus. Connecting lands on the new connection's page, its +// permissions first. +export function AddEmailPage() { + const navigate = useNavigate(); + + return ( + + + Connect a mailbox + + } + > +
+

+ Sign in with an app password made for this connection alone in your mail account. Revoking + it there disconnects the mailbox. +

+ navigate(`/connections/${connection.id}?added`)} + /> +
+
+ ); +} diff --git a/packages/web/src/pages/agents-page.tsx b/packages/web/src/pages/agents-page.tsx index 4e32672..6cd73d2 100644 --- a/packages/web/src/pages/agents-page.tsx +++ b/packages/web/src/pages/agents-page.tsx @@ -8,6 +8,7 @@ import { type AgentProviderId, } from '@proxy/integrations'; import {createAgent} from '@/client/agents-client'; +import {AgentProviderTile} from '@/components/agent-provider-tile'; import {AgentLogo} from '@/components/brand-logo'; import {AppShell, PageTitle} from '@/components/app-shell'; import {EmptyRows, Row, RowHeader, RowList} from '@/components/row-list'; @@ -167,25 +168,13 @@ function NewAgentDialog({ Agent
{providers.map((provider, index) => ( - + onChoose={() => setProviderId(provider.id)} + /> ))}
diff --git a/packages/web/src/pages/connection-page.tsx b/packages/web/src/pages/connection-page.tsx index c769111..730f14a 100644 --- a/packages/web/src/pages/connection-page.tsx +++ b/packages/web/src/pages/connection-page.tsx @@ -16,6 +16,7 @@ import { actionsFor, agentsWithAccess, defaultActions, + defaultsOnOrOff, integrationOf, ownSettings, describeActions, @@ -76,7 +77,7 @@ export function ConnectionPage() { defaults={defaultActions(connection)} differing={differingAgents(agents, connection.id)} onChange={async (actions) => { - const result = await setConnectionDefaults(connection.id, onOrOff(actions)); + const result = await setConnectionDefaults(connection.id, defaultsOnOrOff(actions)); setError(result.isErr() ? describeFetchError(result.error) : null); await revalidator.revalidate(); }} @@ -170,10 +171,3 @@ function differingAgents( } return counts; } - -// A default is on or off; nothing on this page returns one to anything else. -function onOrOff(actions: Record): Record { - return Object.fromEntries( - Object.entries(actions).flatMap(([id, allowed]) => (allowed === null ? [] : [[id, allowed]])), - ); -} diff --git a/packages/web/src/pages/welcome-page.tsx b/packages/web/src/pages/welcome-page.tsx new file mode 100644 index 0000000..0448911 --- /dev/null +++ b/packages/web/src/pages/welcome-page.tsx @@ -0,0 +1,615 @@ +import { + AGENT_PROVIDERS, + findAgentProvider, + type Action, + type AgentProvider, + type AgentProviderId, +} from '@proxy/integrations'; +import {CheckIcon, LoaderCircleIcon} from 'lucide-react'; +import {useEffect, useState, type ReactNode} from 'react'; +import {Link, useLoaderData, useNavigate, useRevalidator, useSearchParams} from 'react-router'; +import {createAgent, resetAgentPassword} from '@/client/agents-client'; +import {setConnectionDefaults} from '@/client/connections-client'; +import {finishOnboarding} from '@/client/me-client'; +import {AgentProviderTile} from '@/components/agent-provider-tile'; +import {ConnectEmailForm} from '@/components/connect-email-form'; +import {ConnectionAccess} from '@/components/connection-access'; +import {CopyButton} from '@/components/copy-button'; +import {IntegrationCatalog} from '@/components/integration-catalog'; +import {SuccessLine} from '@/components/success-line'; +import {Button} from '@proxy/ui/components/button'; +import {defaultActions, defaultsOnOrOff, integrationOf} from '@/lib/access'; +import {signInFailedMessage} from '@/lib/integrations'; +import {describeFetchError} from '@/lib/loader-utils'; +import type {AgentLogin, Connection} from '@/lib/types'; +import type {welcomeConnectionLoader, welcomeLoader} from '@/loaders'; + +// How often the agent step asks whether the MCP client has signed in yet. +const POLL_MS = 3000; + +// Step one is the agent; the rest are step two: the catalog, the mailbox form, and (on its own +// page, `WelcomeConnectionPage`) a new connection's permissions. +type Step = 'agent' | 'connections' | 'email'; + +// Whether an agent elsewhere on the internet can reach this Personal Agent Proxy: an MCP client +// needs a public https address, and a login's sign-in page is no use to a remote agent otherwise. +const PUBLIC = window.location.protocol === 'https:'; + +const SELF_HOSTING_URL = 'https://docs.personalagentproxy.com/self-hosting/get-started/'; + +// The welcome flow, before the app: step one connects the person's agent, in the way that agent +// takes (an MCP client adds the server and signs in; another is handed a login), step two their +// 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 navigate = useNavigate(); + const [finishing, setFinishing] = useState(false); + const [error, setError] = useState(null); + const agentName = agentNameOf(agents); + + const finish = async () => { + setFinishing(true); + const result = await finishOnboarding(); + if (result.isErr()) { + setFinishing(false); + setError(describeFetchError(result.error)); + return; + } + navigate('/connections'); + }; + + return ( + + {step === 'agent' && ( + navigate('/welcome/connections')} /> + )} + {step === 'connections' && ( + void finish()} + /> + )} + {step === 'email' && ( + navigate(`/welcome/connections/${connection.id}`)} /> + )} + {error &&

{error}

} + {step === 'agent' && ( + + )} + + ); +} + +// A connection just made in the flow: what the agent may do with it, confirmed before going on. +export function WelcomeConnectionPage() { + const {agents, connection} = useLoaderData(); + const integration = connection && integrationOf(connection); + if (!connection || !integration) { + return ( + +

This connection does not exist.

+ + + ); + } + + return ( + + + + ); +} + +// The agent the flow is for: the first login made, else a stand-in. +function agentNameOf(agents: AgentLogin[]): string { + return agents.find((agent) => agent.revokedAt === null)?.name ?? 'your agent'; +} + +function Frame({step, children}: {step: 1 | 2; children: ReactNode}) { + return ( +
+
+

Step {step} of 2

+ {children} +
+
+ ); +} + +// What a Personal Agent Proxy on an http address, such as a development machine, is told: only +// an agent on this computer can reach it; the rest need it on a server with a domain. +function NotPublicCallout() { + return ( +
+

+ Only an agent on this computer can reach Personal Agent Proxy at{' '} + {window.location.origin}. The others need a public https + address. +

+

+ For those, run Personal Agent Proxy on a server reachable from the internet, such as a VPS, + and give it a domain name.{' '} + + How to set that up + +

+
+ ); +} + +// One value to copy, such as the server's address: its label, the value in monospace, and a +// copy button. +function CopyLine({label, value, copyLabel}: {label: string; value: string; copyLabel: string}) { + return ( +
+ {label} + {value} + +
+ ); +} + +function Heading({title, children}: {title: string; children?: ReactNode}) { + return ( +
+

{title}

+ {children &&

{children}

} +
+ ); +} + +type Login = {username: string; password: string}; + +// Which agent, then what that agent needs. Choosing one that is handed a login makes the login +// then and there. An agent that has a login already is shown as it is, so arriving here after +// signing in from Claude says Claude is connected. +function AgentStep({agents, onContinue}: {agents: AgentLogin[]; onContinue: () => void}) { + const live = agents.filter((agent) => agent.revokedAt === null); + const {revalidate} = useRevalidator(); + const [chosenId, setChosenId] = useState( + () => findAgentProvider(live[0]?.providerId ?? '')?.id ?? null, + ); + const [login, setLogin] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const chosen = chosenId ? findAgentProvider(chosenId) : undefined; + const existingFor = (provider: AgentProvider) => + live.find((agent) => agent.providerId === provider.id) ?? null; + + const choose = async (provider: AgentProvider) => { + setChosenId(provider.id); + setLogin(null); + setError(null); + if (provider.connects !== 'login' || existingFor(provider) !== null) { + return; + } + setPending(true); + const result = await createAgent(provider.id); + setPending(false); + if (result.isErr()) { + setError(describeFetchError(result.error)); + return; + } + setLogin({username: result.value.agent.username, password: result.value.password}); + void revalidate(); + }; + + const reset = async (agent: AgentLogin) => { + setPending(true); + setError(null); + const result = await resetAgentPassword(agent.id); + setPending(false); + if (result.isErr()) { + setError(describeFetchError(result.error)); + return; + } + setLogin({username: agent.username, password: result.value.password}); + }; + + if (!chosen) { + // Over http, only an agent on this computer can reach Personal Agent Proxy: those first, + // the rest greyed out. + const providers = PUBLIC + ? AGENT_PROVIDERS + : [...AGENT_PROVIDERS].sort((a, b) => Number(b.local) - Number(a.local)); + return ( + <> + + Personal Agent Proxy gives it your connections and information, with every request it + makes in the activity log. + + {!PUBLIC && } +
+ {providers.map((provider) => { + const usable = PUBLIC || provider.local; + return ( + void choose(provider)} + /> + ); + })} +
+ + ); + } + + const existing = existingFor(chosen); + const another = ( + + ); + if (chosen.connects === 'mcp') { + return ( + + ); + } + return ( + existing && void reset(existing)} + onContinue={onContinue} + /> + ); +} + +// Where each MCP client keeps its connectors, as the docs describe it; Claude Code takes a command +// instead, run in a terminal with the server's address. +const MCP_STEPS: Record< + string, + {where: string; url?: string; command?: (serverUrl: string) => string} +> = { + claude: { + where: 'In Claude, open Settings, then Connectors, and choose Add custom connector.', + url: 'https://claude.ai/settings/connectors', + }, + 'claude-code': { + where: 'Run the command above in a terminal.', + command: (serverUrl) => `claude mcp add --transport http personal-agent-proxy ${serverUrl}`, + }, + 'claude-desktop': { + where: 'In Claude Desktop, open Settings, then Connectors, and choose Add custom connector.', + }, + chatgpt: { + where: + 'In ChatGPT, open Settings, then Apps & Connectors, turn on Developer mode under Advanced, and create a connector.', + }, + poke: { + where: + 'On poke.com, under Integrations, add a new one: a name and the address, with the API key left empty.', + url: 'https://poke.com/integrations/new', + }, +}; + +// The server's address to add, and a wait for the client to sign in: when it has, the login it +// made is here. An MCP client reaches the server over the internet, at an https address, so a +// server that isn't one, such as a development machine, is told to move first. +function McpAgent({ + provider, + existing, + another, + onContinue, +}: { + provider: AgentProvider; + existing: AgentLogin | null; + another: ReactNode; + onContinue: () => void; +}) { + const serverUrl = `${window.location.origin}/mcp`; + const reachable = PUBLIC || provider.local; + const steps = MCP_STEPS[provider.id]; + const command = steps?.command?.(serverUrl); + const {revalidate} = useRevalidator(); + const connected = existing !== null; + + useEffect(() => { + if (connected || !reachable) { + return; + } + const timer = setInterval(() => void revalidate(), POLL_MS); + return () => clearInterval(timer); + }, [connected, reachable, revalidate]); + + return ( + <> + + {provider.name} works through Personal Agent Proxy as a connector, signed in with its own + login. + + {!reachable && } + {command ? ( + + ) : ( + + )} +
    +
  1. + {steps?.where ?? `In ${provider.name}, add a custom connector.`}{' '} + {steps?.url && ( + + Open {provider.name} + + )} +
  2. +
  3. + {command + ? `In ${provider.name}, run /mcp and choose personal-agent-proxy to sign in.` + : 'Paste the server address and add it.'} +
  4. +
  5. + {provider.name} opens Personal Agent Proxy to sign in. Choose {provider.name} when asked + which agent it works as. +
  6. +
+ {reachable && ( +

+ {connected ? ( + <> + + {provider.name} is signed in as {existing.name}. + + ) : ( + <> + + Waiting for {provider.name} to sign in… + + )} +

+ )} +
+ {another} + +
+ + ); +} + +// The login, as a message to hand the agent. One that exists already keeps its password, unless +// a new one is asked for here. +function LoginAgent({ + provider, + existing, + login, + pending, + error, + another, + onReset, + onContinue, +}: { + provider: AgentProvider; + existing: AgentLogin | null; + login: Login | null; + pending: boolean; + error: string | null; + another: ReactNode; + onReset: () => void; + onContinue: () => void; +}) { + return ( + <> + + {provider.name} signs in to Personal Agent Proxy with a login of its own. Send it this + message, and it can get started. + + {login ? ( + + ) : ( +
+ {pending && ( +

+ + Making the login… +

+ )} + {!pending && existing && ( + <> +

+ {provider.name} already has a login, {existing.username}. Its password was shown + when it was made; if it is lost, make a new one. +

+ + + )} + {!pending && !existing && !error && ( +

The login could not be made.

+ )} +
+ )} + {error &&

{error}

} +
+ {another} + +
+ + ); +} + +// The message to paste to the agent: the sign-in page and the login, shown this once. +function HandoverMessage({login}: {login: Login}) { + const signInUrl = `${window.location.origin}/agent/login`; + const message = [ + 'Here is my Personal Agent Proxy login. It holds my connections and personal information; sign in with it when a task needs them.', + '', + `Sign-in page: ${signInUrl}`, + `Username: ${login.username}`, + `Password: ${login.password}`, + ].join('\n'); + + return ( +
+
{message}
+
+

+ The password is not shown again once you leave this page. +

+ +
+
+ ); +} + +// The catalog: a plus per integration, and how many accounts of it are connected. A mailbox goes +// to the form; a sign-in starts at the api and comes back to the connection's page here. +function ConnectionsStep({ + agentName, + connections, + finishing, + onDone, +}: { + agentName: string; + connections: Connection[]; + 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 ( + <> + + Add an account, then choose what {agentName} may do with it. The rest can be connected + later. + + {failed &&

{failed}

} + navigate('/welcome/connections/email')} + /> +
+ + +
+ + ); +} + +// The mailbox form. Test connection signs in without saving; Continue makes the connection and +// goes on to its permissions. +function EmailStep({onContinue}: {onContinue: (connection: Connection) => void}) { + return ( + <> + + Sign in with an app password made for this connection alone in your mail account. Revoking + it there disconnects the mailbox. + + } + > + Back + + } + continueLabel="Continue" + onConnected={onContinue} + /> + + ); +} + +// A new connection's permissions: one checkbox per action, saved as the connection's defaults as +// they are ticked, and Confirm back to the catalog. +function ConnectionStep({ + agentName, + connection, + actions, +}: { + agentName: string; + connection: Connection; + actions: Action[]; +}) { + const navigate = useNavigate(); + const {revalidate} = useRevalidator(); + const [error, setError] = useState(null); + const defaults = defaultActions(connection); + + return ( + <> + + Every agent gets what is ticked here unless its own page says otherwise. You can change it + any time on the connection's page. + + Connection added successfully +
+ { + const result = await setConnectionDefaults(connection.id, defaultsOnOrOff(changes)); + setError(result.isErr() ? describeFetchError(result.error) : null); + await revalidate(); + }} + /> +
+ {defaults.length === 0 && ( +

+ Nothing is ticked, so {agentName} can't use this account yet. +

+ )} + {error &&

{error}

} +
+ +
+ + ); +} diff --git a/packages/web/src/router.tsx b/packages/web/src/router.tsx index 5c2b42d..b482cb0 100644 --- a/packages/web/src/router.tsx +++ b/packages/web/src/router.tsx @@ -9,15 +9,19 @@ import {sanitizeCallbackUrl} from '@/lib/callback-url'; import {unwrapLoaderResult} from '@/lib/loader-utils'; import { activityLoader, + addConnectionLoader, agentLoader, agentsLoader, connectAgentLoader, connectionLoader, connectionsLoader, infoLoader, + welcomeConnectionLoader, + welcomeLoader, } from '@/loaders'; import {ActivityPage} from '@/pages/activity-page'; import {AddConnectionPage} from '@/pages/add-connection-page'; +import {AddEmailPage} from '@/pages/add-email-page'; import {AgentPage} from '@/pages/agent-page'; import {AgentsPage} from '@/pages/agents-page'; import {AgentCollectionPage} from '@/pages/agent/agent-collection-page'; @@ -30,10 +34,16 @@ import {ConnectionPage} from '@/pages/connection-page'; import {ConnectionsPage} from '@/pages/connections-page'; import {InfoPage} from '@/pages/info-page'; import {LoginPage} from '@/pages/login-page'; +import {WelcomeConnectionPage, WelcomePage} from '@/pages/welcome-page'; -// The human side's guard: no session sends the user to /login, and back here after. +// The human side's guard: no session sends the user to /login, and back here after. An +// organization that has not been through the welcome flow goes there first. async function humanLoader() { - return unwrapLoaderResult(await getMe()); + const me = unwrapLoaderResult(await getMe()); + if (!me.onboarded) { + throw redirect('/welcome'); + } + return me; } // A signed-in user has no business on the login page; anything else, an api outage included, @@ -58,6 +68,32 @@ export const router = createBrowserRouter([ element: , errorElement: , }, + // The welcome flow: the person's agent, then their connections, before the app itself. A + // connection made in it gets a page of its own, for its permissions. + { + path: '/welcome', + loader: welcomeLoader, + element: , + errorElement: , + }, + { + path: '/welcome/connections', + loader: welcomeLoader, + element: , + errorElement: , + }, + { + path: '/welcome/connections/email', + loader: welcomeLoader, + element: , + errorElement: , + }, + { + path: '/welcome/connections/:id', + loader: welcomeConnectionLoader, + element: , + errorElement: , + }, { id: 'human', loader: humanLoader, @@ -68,7 +104,8 @@ export const router = createBrowserRouter([ children: [ {path: '/', element: }, {path: '/connections', loader: connectionsLoader, element: }, - {path: '/connections/new', element: }, + {path: '/connections/new', loader: addConnectionLoader, element: }, + {path: '/connections/new/email', element: }, {path: '/connections/:id', loader: connectionLoader, element: }, {path: '/info', loader: infoLoader, element: }, {path: '/agents', loader: agentsLoader, element: },