Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions packages/api/src/me/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
38 changes: 37 additions & 1 deletion packages/api/src/me/route.ts
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<Result<void, ApiError>> {
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<void> {
const deleted = await deleteAccount(request.user.userId);
if (deleted.isErr()) {
sendApiError(res, deleted.error);
return;
}
clearSessionCookie(res);
res.status(204).end();
}
3 changes: 2 additions & 1 deletion packages/api/src/server/create-api-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down
7 changes: 6 additions & 1 deletion packages/db/src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result<void, ApiError>> {
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({
Expand Down
8 changes: 8 additions & 0 deletions packages/web/src/client/me-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
43 changes: 43 additions & 0 deletions packages/web/src/components/delete-account-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/// <reference types="bun" />

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<string | null>) {
render(
<DeleteAccountDialog open email="alex@example.com" onClose={mock()} onDelete={onDelete} />,
);
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();
});
});
85 changes: 85 additions & 0 deletions packages/web/src/components/delete-account-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>;
};

// 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<string | null>(null);
const [deleting, setDeleting] = useState(false);
const ready = typed.trim().toLowerCase() === email.toLowerCase();

return (
<Dialog
open={open}
onOpenChange={(next) => {
if (next || deleting) {
return;
}
onClose();
}}
>
<DialogContent>
<form
className="grid gap-4"
onSubmit={async (event) => {
event.preventDefault();
if (!ready) {
return;
}
setDeleting(true);
const failed = await onDelete();
setDeleting(false);
setError(failed);
}}
>
<DialogHeader>
<DialogTitle>Delete your account?</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="delete-account-email">Type {email} to confirm</Label>
<Input
id="delete-account-email"
autoFocus
autoComplete="off"
value={typed}
onChange={(event) => setTyped(event.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button type="button" variant="outline" disabled={deleting} onClick={onClose}>
Cancel
</Button>
<Button type="submit" variant="destructive" disabled={!ready || deleting}>
{deleting ? 'Deleting…' : 'Delete account'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
11 changes: 11 additions & 0 deletions packages/web/src/components/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
LogOutIcon,
PlugIcon,
ScrollTextIcon,
SettingsIcon,
type LucideIcon,
} from 'lucide-react';
import {Link, useLocation, useRouteLoaderData} from 'react-router';
Expand Down Expand Up @@ -59,6 +60,16 @@ export function AppSidebar() {
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith('/settings')}
tooltip="Settings"
render={<Link to="/settings" onClick={() => setOpenMobile(false)} />}
>
<SettingsIcon />
<span>Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<ThemeMenu />
</SidebarMenuItem>
Expand Down
Loading
Loading