From 6bb2333c5ac1ad55562340cd26160f1dd0e20fe2 Mon Sep 17 00:00:00 2001
From: hkonsti <45428767+hkonsti@users.noreply.github.com>
Date: Thu, 15 Oct 2026 19:39:45 +0000
Subject: [PATCH 1/3] add a settings page with delete account
Deleting an account undoes each connection at its provider first, as disconnecting does,
then deletes the user and their organization with everything in it, and signs out. The
dialog asks for the account's address before it deletes anything.
---
AGENTS.md | 2 +-
packages/api/src/me/route.test.ts | 70 +++++++++++++++
packages/api/src/me/route.ts | 38 ++++++++-
packages/api/src/server/create-api-router.ts | 3 +-
packages/db/src/user.ts | 7 +-
packages/web/src/client/me-client.ts | 8 ++
.../components/delete-account-dialog.test.tsx | 43 ++++++++++
.../src/components/delete-account-dialog.tsx | 85 +++++++++++++++++++
packages/web/src/components/nav.tsx | 11 +++
packages/web/src/pages/settings-page.tsx | 68 +++++++++++++++
packages/web/src/router.tsx | 2 +
packages/website/src/app/privacy/page.tsx | 7 +-
packages/website/src/app/terms/page.tsx | 11 +--
packages/website/src/lib/legal.ts | 2 +-
14 files changed, 344 insertions(+), 13 deletions(-)
create mode 100644 packages/web/src/components/delete-account-dialog.test.tsx
create mode 100644 packages/web/src/components/delete-account-dialog.tsx
create mode 100644 packages/web/src/pages/settings-page.tsx
diff --git a/AGENTS.md b/AGENTS.md
index 0d2bf9c..d9f993f 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 Gmail, Calendar, Drive, Docs, Sheets, Slides and Contacts 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 and a spreadsheet's ranges 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. A record carries `url` beside its fields (`DataRecord.url`, never a field): its page in the provider's own app, for an agent to hand the person, since the agent side's pages only open with the agent's login. It is set wherever the provider has one (Gmail, Granola, Notion, Linear, Calendar, Drive, a tab or a row of a sheet, Contacts), on lists too, and the collection's `openIn` names where it opens ("Open in Gmail"); a mailbox over IMAP, a database and Information have none, and the agent is told to name the record instead.
- **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, all the way down (Archive needs Read; Invite guests needs Create and edit events, which needs Read events; Send needs nothing, so an agent can send without reading the mailbox), and a field can name an action of its own (`Field.action`), which `runAgentTool` asks for beyond the write wherever the field holds anything, before the write or after it, refusing and logging as denied without it, 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; 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, an integration whose actions name a `group` (Google's Gmail, Calendar, Drive and Contacts) showing each group under its name with how many are on, an Enable all button above the actions (Disable all once every one the sign-in covered is on; on the agent page it sets the agent's own settings), a new Google connection starting with nothing on, 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, an integration whose actions name a `group` (Google's Gmail, Calendar, Drive and Contacts) showing each group under its name with how many are on, an Enable all button above the actions (Disable all once every one the sign-in covered is on; on the agent page it sets the agent's own settings), a new Google connection starting with nothing on, 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 {…}"), `/settings` (the account: its address, and Delete account, behind a dialog that asks for the address typed again: `DELETE /api/me` undoes each connection at its provider as disconnecting does, `beforeDisconnect`, then deletes the user and their organization with everything in it, `deleteUser`, which also drops sign-in links waiting for the address, and clears the session cookie). 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` (a record with a `url` shows its Open in … link with the address written out, each list row the link beside it, and both pages say to give the person that link, or, without one, to name the record), with a button per command the agent may run on the record, Send beside Save as draft on a new email, Write beside Read on a range, 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. Every page but the form itself ends with a link to `/agent/feedback`, the same feedback as `send_feedback`, carrying the connection and tool it was on. 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/me/route.test.ts b/packages/api/src/me/route.test.ts
index 504488a..d46e04e 100644
--- a/packages/api/src/me/route.test.ts
+++ b/packages/api/src/me/route.test.ts
@@ -4,11 +4,27 @@ import {Err, Ok} from 'ts-results-es';
import {ApiErr} from '@proxy/utils';
const getUserFromId = mock();
+const deleteUser = mock();
const getUserOrganization = mock();
const markOrganizationOnboarded = mock();
+const listConnectionsWithCredential = mock();
+const beforeDisconnect = mock();
+const clearSessionCookie = mock();
mock.module('@proxy/db/user', () => ({
getUserFromId,
+ deleteUser,
+}));
+
+mock.module('@proxy/db/connection', () => ({listConnectionsWithCredential}));
+
+mock.module('../connections/disconnect', () => ({beforeDisconnect}));
+
+mock.module('../auth/session', () => ({clearSessionCookie}));
+
+mock.module('../observability/log', () => ({
+ log: {debug: mock(), info: mock(), warn: mock(), error: mock()},
+ serializeError: mock(() => ({})),
}));
mock.module('@proxy/db/organization', () => ({
@@ -35,6 +51,9 @@ beforeEach(() => {
getUserFromId.mockResolvedValue(Ok(user));
getUserOrganization.mockResolvedValue(Ok({id: 'org-1', onboardedAt: new Date('2026-02-01T00:00:00Z')}));
markOrganizationOnboarded.mockResolvedValue(Ok(undefined));
+ listConnectionsWithCredential.mockResolvedValue(Ok([]));
+ beforeDisconnect.mockResolvedValue(undefined);
+ deleteUser.mockResolvedValue(Ok(undefined));
});
describe('handleMeRoute', () => {
@@ -114,3 +133,54 @@ describe('handleFinishOnboardingRoute', () => {
expect(markOrganizationOnboarded).not.toHaveBeenCalled();
});
});
+
+function makeRes() {
+ const res = {status: mock(), end: mock(), json: mock()};
+ res.status.mockReturnValue(res);
+ return res;
+}
+
+describe('handleDeleteMeRoute', () => {
+ const google = {id: 'conn-1', integrationId: 'google', credential: 'enc:google'};
+ const bank = {id: 'conn-2', integrationId: 'plaid', credential: 'enc:plaid'};
+
+ test('undoes every connection at its provider, then deletes the user and their organization, and signs out', async () => {
+ listConnectionsWithCredential.mockResolvedValue(Ok([google, bank]));
+ const order: string[] = [];
+ beforeDisconnect.mockImplementation(async (connection: {id: string}) => {
+ order.push(connection.id);
+ });
+ deleteUser.mockImplementation(async () => {
+ order.push('user');
+ return Ok(undefined);
+ });
+ const {handleDeleteMeRoute} = await import('./route');
+ const res = makeRes();
+ await handleDeleteMeRoute(makeRequest() as never, res as never);
+
+ expect(listConnectionsWithCredential).toHaveBeenCalledWith('org-1');
+ expect(order).toEqual(['conn-1', 'conn-2', 'user']);
+ expect(deleteUser).toHaveBeenCalledWith('user-1');
+ expect(clearSessionCookie).toHaveBeenCalledWith(res);
+ expect(res.status).toHaveBeenCalledWith(204);
+ });
+
+ test('a failed deletion keeps the person signed in, saying so', async () => {
+ deleteUser.mockResolvedValue(Err(ApiErr.dbError(new Error('down'))));
+ const {handleDeleteMeRoute} = await import('./route');
+ const res = makeRes();
+ await handleDeleteMeRoute(makeRequest() as never, res as never);
+
+ expect(clearSessionCookie).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(500);
+ });
+
+ test('a user without an organization is still deleted', async () => {
+ getUserOrganization.mockResolvedValue(Ok(null));
+ const {handleDeleteMeRoute} = await import('./route');
+ await handleDeleteMeRoute(makeRequest() as never, makeRes() as never);
+
+ expect(listConnectionsWithCredential).not.toHaveBeenCalled();
+ expect(deleteUser).toHaveBeenCalledWith('user-1');
+ });
+});
diff --git a/packages/api/src/me/route.ts b/packages/api/src/me/route.ts
index fc18afd..a4b312b 100644
--- a/packages/api/src/me/route.ts
+++ b/packages/api/src/me/route.ts
@@ -1,10 +1,16 @@
+import type {Response} from 'express';
import {Err, type Result} from 'ts-results-es';
import {ApiErr, type ApiError, Do, requirePresent} from '@proxy/utils';
+import {listConnectionsWithCredential} from '@proxy/db/connection';
import {getUserOrganization, markOrganizationOnboarded} from '@proxy/db/organization';
-import {getUserFromId} from '@proxy/db/user';
+import {deleteUser, getUserFromId} from '@proxy/db/user';
+import {clearSessionCookie} from '../auth/session';
+import {beforeDisconnect} from '../connections/disconnect';
+import {log} from '../observability/log';
import type {AuthenticatedRequest} from '../server/middleware/require-auth';
+import {sendApiError} from '../server/response';
export type MeResponse = {
user: {id: string; email: string; name: string | null; image: string | null};
@@ -36,3 +42,33 @@ export function handleFinishOnboardingRoute(request: AuthenticatedRequest): Prom
$(await markOrganizationOnboarded(organization.id));
});
}
+
+/**
+ * Deletes the signed-in person's account: each connection is undone at its provider first, as
+ * disconnecting it does (Google's grant revoked, a bank's login removed at Plaid), then the user
+ * and their organization, with its connections, information, agents, their logins and MCP
+ * clients, and the activity log. A provider that can't be reached doesn't stop the deletion; it is
+ * logged, as disconnecting logs it.
+ */
+function deleteAccount(userId: string): Promise> {
+ return Do(async ($) => {
+ const organization = $(await getUserOrganization(userId));
+ const connections = organization ? $(await listConnectionsWithCredential(organization.id)) : [];
+ for (const connection of connections) {
+ await beforeDisconnect(connection);
+ }
+ $(await deleteUser(userId));
+ log.info('Deleted an account', {userId, connections: connections.length});
+ });
+}
+
+/** `DELETE /api/me` — deletes the account and signs out: the session went with the user, and its cookie goes now. */
+export async function handleDeleteMeRoute(request: AuthenticatedRequest, res: Response): Promise {
+ const deleted = await deleteAccount(request.user.userId);
+ if (deleted.isErr()) {
+ sendApiError(res, deleted.error);
+ return;
+ }
+ clearSessionCookie(res);
+ res.status(204).end();
+}
diff --git a/packages/api/src/server/create-api-router.ts b/packages/api/src/server/create-api-router.ts
index c43a7b7..86a0457 100644
--- a/packages/api/src/server/create-api-router.ts
+++ b/packages/api/src/server/create-api-router.ts
@@ -28,7 +28,7 @@ import {handleNotionCallbackRoute, handleStartNotionRoute} from '../connections/
import {handleConnectPostgresRoute, handleTestPostgresRoute} from '../connections/postgres/route';
import {handleDeleteConnectionRoute, handleGetConnectionRoute, handleListConnectionsRoute, handleRenameConnectionRoute, handleSetConnectionDefaultsRoute} from '../connections/route';
import {handleMcpMethodNotAllowedRoute, handleMcpRoute} from '../mcp/route';
-import {handleFinishOnboardingRoute, handleMeRoute} from '../me/route';
+import {handleDeleteMeRoute, handleFinishOnboardingRoute, handleMeRoute} from '../me/route';
import {handleAuthorizeRoute, handleConsentRoute, handleGetAuthorizationRequestRoute} from '../oauth/authorize';
import {handleAuthorizationServerMetadataRoute, handleProtectedResourceMetadataRoute} from '../oauth/metadata';
import {handleRegisterClientRoute} from '../oauth/register';
@@ -60,6 +60,7 @@ 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.delete('/api/me', withAuth('Delete account route', handleDeleteMeRoute));
router.use('/api/connections', requireBrowserOrigin);
router.get('/api/connections', withAuthResult('List connections route', handleListConnectionsRoute));
diff --git a/packages/db/src/user.ts b/packages/db/src/user.ts
index 4c6bd14..30f8322 100644
--- a/packages/db/src/user.ts
+++ b/packages/db/src/user.ts
@@ -107,12 +107,17 @@ export async function createUser(data: {
/**
* Deletes the user and their organization; the cascades take their accounts, sessions,
- * membership and the organization's connections. Organizations have one member for now, so every
+ * membership and the organization's connections, information, agents and activity. Sign-in links
+ * still waiting for their address go too. Organizations have one member for now, so every
* organization the user belongs to is theirs alone.
*/
export async function deleteUser(userId: string): Promise> {
return wrapDb(() =>
db.$transaction(async (tx) => {
+ const user = await tx.user.findUnique({where: {id: userId}, select: {email: true}});
+ if (user) {
+ await tx.verificationToken.deleteMany({where: {identifier: user.email}});
+ }
// By id: Prisma cascades to the memberships first, so filtering the delete itself by
// membership would match nothing.
const orgs = await tx.organization.findMany({
diff --git a/packages/web/src/client/me-client.ts b/packages/web/src/client/me-client.ts
index ef78722..bc6ee83 100644
--- a/packages/web/src/client/me-client.ts
+++ b/packages/web/src/client/me-client.ts
@@ -34,3 +34,11 @@ export function getMe() {
export function finishOnboarding() {
return apiSend('POST', '/api/me/onboarded');
}
+
+/**
+ * Deletes the account: every connection is undone at its provider, then the user and everything
+ * in their organization; the session goes with it.
+ */
+export function deleteAccount() {
+ return apiSend('DELETE', '/api/me');
+}
diff --git a/packages/web/src/components/delete-account-dialog.test.tsx b/packages/web/src/components/delete-account-dialog.test.tsx
new file mode 100644
index 0000000..0b74f42
--- /dev/null
+++ b/packages/web/src/components/delete-account-dialog.test.tsx
@@ -0,0 +1,43 @@
+///
+
+import {afterEach, describe, expect, mock, test} from 'bun:test';
+import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {DeleteAccountDialog} from '@/components/delete-account-dialog';
+
+afterEach(() => {
+ cleanup();
+});
+
+function renderDialog(onDelete: () => Promise) {
+ render(
+ ,
+ );
+ return {
+ field: screen.getByLabelText('Type alex@example.com to confirm'),
+ button: screen.getByRole('button', {name: 'Delete account'}),
+ };
+}
+
+describe('DeleteAccountDialog', () => {
+ test('deletes only once the account’s address is typed, in any case', async () => {
+ const onDelete = mock(async () => null);
+ const {field, button} = renderDialog(onDelete);
+
+ expect(button.hasAttribute('disabled')).toBe(true);
+ fireEvent.change(field, {target: {value: 'alex@example'}});
+ expect(button.hasAttribute('disabled')).toBe(true);
+ fireEvent.change(field, {target: {value: ' Alex@Example.com '}});
+ fireEvent.click(button);
+
+ await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1));
+ });
+
+ test('says what went wrong when the deletion fails', async () => {
+ const {field, button} = renderDialog(async () => 'Something went wrong. Try again.');
+
+ fireEvent.change(field, {target: {value: 'alex@example.com'}});
+ fireEvent.click(button);
+
+ expect(await screen.findByText('Something went wrong. Try again.')).toBeDefined();
+ });
+});
diff --git a/packages/web/src/components/delete-account-dialog.tsx b/packages/web/src/components/delete-account-dialog.tsx
new file mode 100644
index 0000000..61ea09b
--- /dev/null
+++ b/packages/web/src/components/delete-account-dialog.tsx
@@ -0,0 +1,85 @@
+import {useState} from 'react';
+import {Button} from '@proxy/ui/components/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@proxy/ui/components/dialog';
+import {Input} from '@proxy/ui/components/input';
+import {Label} from '@proxy/ui/components/label';
+
+type Props = {
+ open: boolean;
+ // The account's address, typed again to confirm.
+ email: string;
+ onClose: () => void;
+ // Deletes the account; what went wrong, shown in the dialog, or null once it is gone.
+ onDelete: () => Promise;
+};
+
+// Deleting the account can't be undone, so it asks for the account's address before it does.
+export function DeleteAccountDialog({open, email, onClose, onDelete}: Props) {
+ const [typed, setTyped] = useState('');
+ const [error, setError] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const ready = typed.trim().toLowerCase() === email.toLowerCase();
+
+ return (
+
+ );
+}
diff --git a/packages/web/src/components/nav.tsx b/packages/web/src/components/nav.tsx
index 77c58a3..70bc4d0 100644
--- a/packages/web/src/components/nav.tsx
+++ b/packages/web/src/components/nav.tsx
@@ -4,6 +4,7 @@ import {
LogOutIcon,
PlugIcon,
ScrollTextIcon,
+ SettingsIcon,
type LucideIcon,
} from 'lucide-react';
import {Link, useLocation, useRouteLoaderData} from 'react-router';
@@ -59,6 +60,16 @@ export function AppSidebar() {
+
+ setOpenMobile(false)} />}
+ >
+
+ Settings
+
+
diff --git a/packages/web/src/pages/settings-page.tsx b/packages/web/src/pages/settings-page.tsx
new file mode 100644
index 0000000..b99a948
--- /dev/null
+++ b/packages/web/src/pages/settings-page.tsx
@@ -0,0 +1,68 @@
+import {useState} from 'react';
+import {useRouteLoaderData} from 'react-router';
+import {deleteAccount, type Me} from '@/client/me-client';
+import {AppShell, PageTitle} from '@/components/app-shell';
+import {DeleteAccountDialog} from '@/components/delete-account-dialog';
+import {RowList} from '@/components/row-list';
+import {Section} from '@/components/section';
+import {Button} from '@proxy/ui/components/button';
+import {describeFetchError} from '@/lib/loader-utils';
+
+// The account itself: who is signed in, and deleting the account with everything in it.
+export function SettingsPage() {
+ const me = useRouteLoaderData('human');
+ const [deleting, setDeleting] = useState(false);
+ if (!me) {
+ return null;
+ }
+
+ return (
+ Settings}>
+
+ Disconnects every connection, at its provider too where it has a way, as Google's access
+ is revoked, and deletes your information, agents and their activity.
+
+
+
+ setDeleting(false)}
+ onDelete={async () => {
+ const result = await deleteAccount();
+ if (result.isErr()) {
+ return describeFetchError(result.error);
+ }
+ // A full load, so nothing of the account stays in memory; the login page is next.
+ window.location.assign('/login');
+ return null;
+ }}
+ />
+
+ );
+}
+
+function AccountRow({label, value}: {label: string; value: string}) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/packages/web/src/router.tsx b/packages/web/src/router.tsx
index 6c59f96..bd75f8d 100644
--- a/packages/web/src/router.tsx
+++ b/packages/web/src/router.tsx
@@ -41,6 +41,7 @@ import {ConnectionsPage} from '@/pages/connections-page';
import {InfoPage} from '@/pages/info-page';
import {LoginPage} from '@/pages/login-page';
import {NewAgentPage} from '@/pages/new-agent-page';
+import {SettingsPage} from '@/pages/settings-page';
import {WelcomeConnectionPage, WelcomePage} from '@/pages/welcome-page';
// The human side's guard: no session sends the user to /login, and back here after. An
@@ -141,6 +142,7 @@ export const router = createBrowserRouter([
{path: '/agents/:id', loader: agentLoader, element: },
{path: '/agents/:id/setup', loader: agentSetupLoader, element: },
{path: '/activity', loader: activityLoader, element: },
+ {path: '/settings', element: },
],
},
{
diff --git a/packages/website/src/app/privacy/page.tsx b/packages/website/src/app/privacy/page.tsx
index af5090a..50c1251 100644
--- a/packages/website/src/app/privacy/page.tsx
+++ b/packages/website/src/app/privacy/page.tsx
@@ -198,9 +198,10 @@ export default function PrivacyPage() {
including for as long as your account exists, and as needed to comply with the law,
resolve disputes and enforce our agreements. Disconnecting a service deletes its
credentials but doesn't by itself delete the other information we collected from it.
- You can delete your account, and have the information in it deleted, by writing to{' '}
- {LEGAL.contactEmail}. We may keep deidentified information, and copies in backups for a
- limited time.
+ You can delete your account, and the information in it, at any time in the Services'
+ settings, or by writing to {LEGAL.contactEmail}. Deleting it disconnects every connected
+ service, revoking our access with that service where it offers a way. We may keep
+ deidentified information, and copies in backups for a limited time.
diff --git a/packages/website/src/app/terms/page.tsx b/packages/website/src/app/terms/page.tsx
index 60e94a8..7bf233b 100644
--- a/packages/website/src/app/terms/page.tsx
+++ b/packages/website/src/app/terms/page.tsx
@@ -163,11 +163,12 @@ export default function TermsPage() {
- You can stop using the Services at any time and ask us to delete your account by writing
- to {LEGAL.contactEmail}. We may suspend or end your access to the Services, or any part of
- them, at any time, with or without notice, for any or no reason, including if we believe
- you broke these terms. On termination your right to use the Services ends; we may, but
- need not, delete Your Content, and the sections that by their nature should survive do so.
+ You can stop using the Services at any time and delete your account in the Services'
+ settings, or ask us to by writing to {LEGAL.contactEmail}. We may suspend or end your
+ access to the Services, or any part of them, at any time, with or without notice, for any
+ or no reason, including if we believe you broke these terms. On termination your right to
+ use the Services ends; we may, but need not, delete Your Content, and the sections that by
+ their nature should survive do so.
diff --git a/packages/website/src/lib/legal.ts b/packages/website/src/lib/legal.ts
index a7ea1d9..bbb29a3 100644
--- a/packages/website/src/lib/legal.ts
+++ b/packages/website/src/lib/legal.ts
@@ -10,4 +10,4 @@ export const LEGAL = {
};
// The day both pages took effect; a change to either moves it.
-export const LEGAL_EFFECTIVE_DATE = 'October 14, 2026';
+export const LEGAL_EFFECTIVE_DATE = 'October 15, 2026';
From 4d8303508c5cc2a39094a5a12fd91db5decab2b0 Mon Sep 17 00:00:00 2001
From: hkonsti <45428767+hkonsti@users.noreply.github.com>
Date: Thu, 15 Oct 2026 19:58:15 +0000
Subject: [PATCH 2/3] show delete account as a danger card
---
packages/web/src/pages/settings-page.tsx | 27 ++++++++++++++----------
1 file changed, 16 insertions(+), 11 deletions(-)
diff --git a/packages/web/src/pages/settings-page.tsx b/packages/web/src/pages/settings-page.tsx
index b99a948..fd77351 100644
--- a/packages/web/src/pages/settings-page.tsx
+++ b/packages/web/src/pages/settings-page.tsx
@@ -25,19 +25,24 @@ export function SettingsPage() {
{me.user.name && }
- setDeleting(true)}>
+
+
+
Delete account
+
+ Permanently delete your account and everything in it. Every connection is
+ disconnected, at its provider too where it has a way, and your information, agents and
+ their activity are deleted. This can't be undone.
+
+
+
+
- }
- >
-
- Disconnects every connection, at its provider too where it has a way, as Google's access
- is revoked, and deletes your information, agents and their activity.
-
-
+
+
Date: Thu, 15 Oct 2026 20:07:02 +0000
Subject: [PATCH 3/3] reword what deleting an account does
---
packages/web/src/components/delete-account-dialog.tsx | 6 +++---
packages/web/src/pages/settings-page.tsx | 6 +++---
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/packages/web/src/components/delete-account-dialog.tsx b/packages/web/src/components/delete-account-dialog.tsx
index 61ea09b..edcc867 100644
--- a/packages/web/src/components/delete-account-dialog.tsx
+++ b/packages/web/src/components/delete-account-dialog.tsx
@@ -54,9 +54,9 @@ export function DeleteAccountDialog({open, email, onClose, onDelete}: Props) {
Delete your account?
- Every connection is disconnected, at its provider too where it has a way, and your
- information, agents and activity are deleted. Agents lose access at once. This can't
- be undone.
+ Every connection is first invalidated at the provider (where possible), and then
+ deleted from Personal Agent Proxy, along with your information, agents and activity.
+ This cannot be undone.
diff --git a/packages/web/src/pages/settings-page.tsx b/packages/web/src/pages/settings-page.tsx
index fd77351..5311db0 100644
--- a/packages/web/src/pages/settings-page.tsx
+++ b/packages/web/src/pages/settings-page.tsx
@@ -29,9 +29,9 @@ export function SettingsPage() {
Delete account
- Permanently delete your account and everything in it. Every connection is
- disconnected, at its provider too where it has a way, and your information, agents and
- their activity are deleted. This can't be undone.
+ Every connection is first invalidated at the provider (where possible), and then
+ deleted from Personal Agent Proxy, along with your information, agents and activity.
+ This cannot be undone.