From 347b41e90169a45326a88bc8ebd3017ae9a8bdb6 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:49:38 +0000 Subject: [PATCH] split settings into your account and the organization's settings Account (/account) has who is signed in, the organizations they are in and deleting the account; the organization's settings (/:org/settings) its name, your role, and deleting or leaving it. The account page shares the signed-in frame with the first organization in the sidebar, and account is now a reserved slug, with a migration renaming any organization that has it. --- .../v032_reserve_account_slug/migration.sql | 12 + packages/db/src/organization.test.ts | 2 +- packages/db/src/organization.ts | 1 + packages/docs/content/organizations.mdx | 6 +- packages/map/src/button-edges.ts | 1 + packages/web/src/components/danger-card.tsx | 35 +++ packages/web/src/components/nav.tsx | 15 +- packages/web/src/components/setting-row.tsx | 20 ++ packages/web/src/pages/account-page.tsx | 91 ++++++++ packages/web/src/pages/org-settings-page.tsx | 147 ++++++++++++ packages/web/src/pages/settings-page.tsx | 217 ------------------ packages/web/src/router.tsx | 49 ++-- 12 files changed, 352 insertions(+), 244 deletions(-) create mode 100644 packages/db/prisma/migrations/v032_reserve_account_slug/migration.sql create mode 100644 packages/web/src/components/danger-card.tsx create mode 100644 packages/web/src/components/setting-row.tsx create mode 100644 packages/web/src/pages/account-page.tsx create mode 100644 packages/web/src/pages/org-settings-page.tsx delete mode 100644 packages/web/src/pages/settings-page.tsx diff --git a/packages/db/prisma/migrations/v032_reserve_account_slug/migration.sql b/packages/db/prisma/migrations/v032_reserve_account_slug/migration.sql new file mode 100644 index 00000000..9b6ff786 --- /dev/null +++ b/packages/db/prisma/migrations/v032_reserve_account_slug/migration.sql @@ -0,0 +1,12 @@ +-- `/account` is the person's account in the web app, so no organization's address can be it. One +-- named so before gets the slug a new one would, the first of `account-2`, `account-3`, … that no +-- organization has. +UPDATE "Organization" +SET "slug" = ( + SELECT 'account-' || n + FROM generate_series(2, 99) AS n + WHERE NOT EXISTS (SELECT 1 FROM "Organization" WHERE "slug" = 'account-' || n) + ORDER BY n + LIMIT 1 +) +WHERE "slug" = 'account'; diff --git a/packages/db/src/organization.test.ts b/packages/db/src/organization.test.ts index 5bef3898..9a29fabe 100644 --- a/packages/db/src/organization.test.ts +++ b/packages/db/src/organization.test.ts @@ -86,7 +86,7 @@ describe('pickAvailableSlugDatabase', () => { }); it('never takes a top-level path of the app or the api', async () => { - for (const path of ['api', 'mcp', 'login', 'invite', 'agent', 'oauth', 'assets']) { + for (const path of ['api', 'mcp', 'login', 'invite', 'agent', 'oauth', 'assets', 'account']) { expect(await pickAvailableSlugDatabase(withSlugs([]), path)).toBe(`${path}-2`); } }); diff --git a/packages/db/src/organization.ts b/packages/db/src/organization.ts index 3e97662a..1fd1fafe 100644 --- a/packages/db/src/organization.ts +++ b/packages/db/src/organization.ts @@ -55,6 +55,7 @@ export function planPersonalOrgIdentity(identity: {name?: string | null; email: // (`/login`, `/agent`, …), of the api it serves on the same origin (`/api`, `/mcp`, …), or of the // web app's files (`/assets`, …). const RESERVED_SLUGS = new Set([ + 'account', 'agent', 'agent-auth', 'agent-providers', diff --git a/packages/docs/content/organizations.mdx b/packages/docs/content/organizations.mdx index 211cb64b..f8e39604 100644 --- a/packages/docs/content/organizations.mdx +++ b/packages/docs/content/organizations.mdx @@ -68,8 +68,10 @@ set up ([Sign-in](/self-hosting/sign-in#sign-in-links)). ## Leaving and deleting -A member leaves an organization from **Members**; its owner can't, and deletes it from -**Settings** instead, as long as it isn't the last one they own, so everyone always owns one. +A member leaves an organization from its **Settings** or **Members**; its owner can't, and deletes +it from its **Settings** instead, as long as it isn't the last one they own, so everyone always owns +one. Your own account is under **Account**, at the foot of the sidebar, with every +organization you are in. Leaving, or being removed, disconnects what you connected for yourself there and deletes your own agents there, and signs out the MCP clients you signed in as its shared agents; the shared connections and agents stay. A shared agent's password a former member knows keeps working until diff --git a/packages/map/src/button-edges.ts b/packages/map/src/button-edges.ts index ce10ae24..8c1fb426 100644 --- a/packages/map/src/button-edges.ts +++ b/packages/map/src/button-edges.ts @@ -12,6 +12,7 @@ export const BUTTON_EDGES: ReadonlyArray<{from: string; to: string; label: strin {from: '/:org', to: '/:org/welcome/connections', label: 'Create organization'}, // Leaving or deleting an organization opens another of the person's. {from: '/:org/members', to: '/:org/connections', label: 'Leave'}, + {from: '/:org/settings', to: '/:org/connections', label: 'Leave organization'}, {from: '/:org/settings', to: '/:org/connections', label: 'Delete organization'}, // The agent lands on its tasks, to open one or start another. {from: '/agent/login', to: '/agent', label: 'Sign in'}, diff --git a/packages/web/src/components/danger-card.tsx b/packages/web/src/components/danger-card.tsx new file mode 100644 index 00000000..755b929e --- /dev/null +++ b/packages/web/src/components/danger-card.tsx @@ -0,0 +1,35 @@ +import {Button} from '@proxy/ui/components/button'; + +// Something that can't be undone, in red: what it does, and its button; disabled, the description +// says what has to happen first. +export function DangerCard({ + title, + description, + button, + disabled, + onClick, +}: { + title: string; + description: string; + button: string; + disabled?: boolean; + onClick: () => void; +}) { + return ( +
+
+

{title}

+

{description}

+
+
+ +
+
+ ); +} diff --git a/packages/web/src/components/nav.tsx b/packages/web/src/components/nav.tsx index dfed1156..c41dacf3 100644 --- a/packages/web/src/components/nav.tsx +++ b/packages/web/src/components/nav.tsx @@ -1,5 +1,6 @@ import { BotIcon, + CircleUserIcon, HomeIcon, LibraryIcon, LogOutIcon, @@ -36,6 +37,7 @@ const DESTINATIONS: Destination[] = [ {to: '/vault', label: 'Vault', icon: LibraryIcon}, {to: '/activity', label: 'Activity', icon: ScrollTextIcon}, {to: '/members', label: 'Members', icon: UsersIcon}, + {to: '/settings', label: 'Settings', icon: SettingsIcon}, ]; // Each destination is lit on its own pages too, as `/acme/connections/:id`; Home only on itself, @@ -46,7 +48,8 @@ function isActive(pathname: string, path: string, home: string): boolean { // The stock shadcn sidebar: a rail in icon mode on desktop, labels in tooltips and ⌘B to expand // it; a sheet from the left on phones, opened from the header and closed again on every pick. The -// organization switcher sits at its top. +// organization switcher sits at its top, with the organization's pages under it; the person's own, +// their account, theme and signing out, at its foot. export function AppSidebar() { const {pathname} = useLocation(); const human = useHuman(); @@ -86,12 +89,12 @@ export function AppSidebar() { setOpenMobile(false)} />} + isActive={pathname === '/account'} + tooltip="Account" + render={ setOpenMobile(false)} />} > - - Settings + + Account diff --git a/packages/web/src/components/setting-row.tsx b/packages/web/src/components/setting-row.tsx new file mode 100644 index 00000000..ad67b4ed --- /dev/null +++ b/packages/web/src/components/setting-row.tsx @@ -0,0 +1,20 @@ +import type {ReactNode} from 'react'; + +// A setting on a settings page: its label, what it is, and an action beside it, as renaming. +export function SettingRow({ + label, + action, + children, +}: { + label: string; + action?: ReactNode; + children: ReactNode; +}) { + return ( +
  • + {label} + {children} + {action} +
  • + ); +} diff --git a/packages/web/src/pages/account-page.tsx b/packages/web/src/pages/account-page.tsx new file mode 100644 index 00000000..d4456266 --- /dev/null +++ b/packages/web/src/pages/account-page.tsx @@ -0,0 +1,91 @@ +import {useState} from 'react'; +import {Link} from 'react-router'; +import {deleteAccount} from '@/client/me-client'; +import type {Org} from '@/client/orgs-client'; +import {AppShell, PageTitle} from '@/components/app-shell'; +import {DangerCard} from '@/components/danger-card'; +import {OrgTile} from '@/components/org-switcher'; +import {RowList} from '@/components/row-list'; +import {Section} from '@/components/section'; +import {SettingRow} from '@/components/setting-row'; +import {TypeToConfirmDialog} from '@/components/type-to-confirm-dialog'; +import {useHuman} from '@/hooks/use-human'; +import {describeApiError} from '@/lib/loader-utils'; +import {hasOtherPeople} from '@/lib/orgs'; + +// The person's account, in no organization: who is signed in, the organizations they are in, each +// to its own settings, and deleting the account, which deletes every one they own. +export function AccountPage() { + const human = useHuman(); + const [deleting, setDeleting] = useState(false); + if (!human) { + return null; + } + + // The organizations deleted with the account that others are in or invited to. + const shared = human.orgs.filter((org) => org.role === 'owner' && hasOtherPeople(org)); + + return ( + Account}> +
    +
    + + {human.user.email} + {human.user.name && {human.user.name}} + +
    +
    + + {human.orgs.map((org) => ( +
  • + + + {org.name} + + + {org.role === 'owner' ? 'Owner' : 'Member'} + +
  • + ))} +
    +
    + setDeleting(true)} + /> +
    + setDeleting(false)} + onConfirm={async () => { + const result = await deleteAccount(); + if (result.isErr()) { + return describeApiError(result.error); + } + // A full load, so nothing of the account stays in memory; the login page is next. + window.location.assign('/login'); + return null; + }} + /> +
    + ); +} + +// What deleting the account deletes beside it: the organizations the person owns, naming those +// others are in, who lose them too. +function deletedWith(shared: Org[]): string { + if (shared.length === 0) { + return 'The organizations you own are deleted with it.'; + } + return `The organizations you own are deleted with it, everything in them included, for everyone in them: ${shared.map((org) => org.name).join(', ')}.`; +} diff --git a/packages/web/src/pages/org-settings-page.tsx b/packages/web/src/pages/org-settings-page.tsx new file mode 100644 index 00000000..899b7625 --- /dev/null +++ b/packages/web/src/pages/org-settings-page.tsx @@ -0,0 +1,147 @@ +import {PencilIcon} from 'lucide-react'; +import {useState} from 'react'; +import {Link, useNavigate, useRevalidator} from 'react-router'; +import {deleteOrg, leaveOrg, renameOrg} from '@/client/orgs-client'; +import {AppShell, PageTitle} from '@/components/app-shell'; +import {ConfirmDialog} from '@/components/confirm-dialog'; +import {DangerCard} from '@/components/danger-card'; +import {IconButton} from '@/components/icon-button'; +import {OrgTile} from '@/components/org-switcher'; +import {RenameDialog} from '@/components/rename-dialog'; +import {RowList} from '@/components/row-list'; +import {Section} from '@/components/section'; +import {SettingRow} from '@/components/setting-row'; +import {TypeToConfirmDialog} from '@/components/type-to-confirm-dialog'; +import {useHuman} from '@/hooks/use-human'; +import {describeApiError} from '@/lib/loader-utils'; + +// The settings of the organization the address names: its name and your place in it. Its owner +// renames and deletes it, unless it is the last one they own; a member leaves it. +export function OrgSettingsPage() { + const human = useHuman(); + const navigate = useNavigate(); + const revalidator = useRevalidator(); + const [deleting, setDeleting] = useState(false); + const [leaving, setLeaving] = useState(false); + const [renaming, setRenaming] = useState(false); + const [error, setError] = useState(null); + if (!human) { + return null; + } + + const organization = human.org; + const owner = organization.role === 'owner'; + const lastOwned = human.orgs.filter((candidate) => candidate.role === 'owner').length <= 1; + // Out of it, the person's first other organization: everyone owns one, and an owner deleting + // it owns another, or it wouldn't go. + const next = human.orgs.find((candidate) => candidate.id !== organization.id) ?? organization; + + return ( + Settings}> +
    + {error &&

    {error}

    } +
    + + setRenaming(true)}> + + + ) + } + > + + + {organization.name} + + + {owner ? 'Owner' : 'Member'} + + + {organization.memberCount} {organization.memberCount === 1 ? 'member' : 'members'} + + + +
    + {owner && ( + setDeleting(true)} + /> + )} + {!owner && ( + setLeaving(true)} + /> + )} +
    + {renaming && ( + setRenaming(false)} + onSave={async (name) => { + const result = await renameOrg(organization.slug, name); + if (result.isErr()) { + return describeApiError(result.error); + } + await revalidator.revalidate(); + return null; + }} + /> + )} + setDeleting(false)} + onConfirm={async () => { + const result = await deleteOrg(organization.slug); + if (result.isErr()) { + return describeApiError(result.error); + } + setDeleting(false); + await navigate( + `/${next.slug}/connections?deleted=${encodeURIComponent(organization.name)}`, + ); + return null; + }} + /> + setLeaving(false)} + onConfirm={async () => { + const result = await leaveOrg(organization.slug); + if (result.isErr()) { + setError(describeApiError(result.error)); + return; + } + await navigate(`/${next.slug}/connections?left=${encodeURIComponent(organization.name)}`); + }} + /> +
    + ); +} diff --git a/packages/web/src/pages/settings-page.tsx b/packages/web/src/pages/settings-page.tsx deleted file mode 100644 index 071a2e1f..00000000 --- a/packages/web/src/pages/settings-page.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import {PencilIcon} from 'lucide-react'; -import {useState, type ReactNode} from 'react'; -import {Link, useNavigate, useRevalidator} from 'react-router'; -import {deleteAccount} from '@/client/me-client'; -import {deleteOrg, renameOrg, type Org} from '@/client/orgs-client'; -import {AppShell, PageTitle} from '@/components/app-shell'; -import {IconButton} from '@/components/icon-button'; -import {OrgTile} from '@/components/org-switcher'; -import {RenameDialog} from '@/components/rename-dialog'; -import {RowList} from '@/components/row-list'; -import {Section} from '@/components/section'; -import {TypeToConfirmDialog} from '@/components/type-to-confirm-dialog'; -import {Button} from '@proxy/ui/components/button'; -import {useHuman} from '@/hooks/use-human'; -import {describeApiError} from '@/lib/loader-utils'; -import {hasOtherPeople} from '@/lib/orgs'; - -type Deleting = 'account' | 'org' | null; - -// The account and the organization open in the switcher: who is signed in, the organization's -// name and your place in it, and deleting either. An organization's owner renames and deletes it, -// unless it is the last one they own; deleting the account deletes every one they own. -export function SettingsPage() { - const human = useHuman(); - const navigate = useNavigate(); - const revalidator = useRevalidator(); - const [deleting, setDeleting] = useState(null); - const [renaming, setRenaming] = useState(false); - if (!human) { - return null; - } - - const organization = human.org; - const owner = organization.role === 'owner'; - const owned = human.orgs.filter((candidate) => candidate.role === 'owner'); - const lastOwned = owned.length <= 1; - // The organizations deleted with the account that others are in or invited to. - const shared = owned.filter(hasOtherPeople); - - return ( - Settings}> -
    -
    - - {human.user.email} - {human.user.name && {human.user.name}} - -
    -
    - - setRenaming(true)}> - - - ) - } - > - - - {organization.name} - - - {owner ? 'Owner' : 'Member'} - - - {organization.memberCount} {organization.memberCount === 1 ? 'member' : 'members'} - - - -
    - {owner && ( - setDeleting('org')} - /> - )} - setDeleting('account')} - /> -
    - {renaming && ( - setRenaming(false)} - onSave={async (name) => { - const result = await renameOrg(organization.slug, name); - if (result.isErr()) { - return describeApiError(result.error); - } - await revalidator.revalidate(); - return null; - }} - /> - )} - setDeleting(null)} - onConfirm={async () => { - const result = await deleteOrg(organization.slug); - if (result.isErr()) { - return describeApiError(result.error); - } - setDeleting(null); - // Gone, the person's first other organization: they own another, or it wouldn't go. - const next = - human.orgs.find((candidate) => candidate.id !== organization.id) ?? organization; - await navigate( - `/${next.slug}/connections?deleted=${encodeURIComponent(organization.name)}`, - ); - return null; - }} - /> - setDeleting(null)} - onConfirm={async () => { - const result = await deleteAccount(); - if (result.isErr()) { - return describeApiError(result.error); - } - // A full load, so nothing of the account stays in memory; the login page is next. - window.location.assign('/login'); - return null; - }} - /> -
    - ); -} - -// What deleting the account deletes beside it: the organizations the person owns, naming those -// others are in, who lose them too. -function deletedWith(shared: Org[]): string { - if (shared.length === 0) { - return 'The organizations you own are deleted with it.'; - } - return `The organizations you own are deleted with it, everything in them included, for everyone in them: ${shared.map((candidate) => candidate.name).join(', ')}.`; -} - -function SettingRow({ - label, - action, - children, -}: { - label: string; - action?: ReactNode; - children: ReactNode; -}) { - return ( -
  • - {label} - {children} - {action} -
  • - ); -} - -// Something that can't be undone, in red: what it does, and its button; disabled, the description -// says what has to happen first. -function DangerCard({ - title, - description, - button, - disabled, - onClick, -}: { - title: string; - description: string; - button: string; - disabled?: boolean; - onClick: () => void; -}) { - return ( -
    -
    -

    {title}

    -

    {description}

    -
    -
    - -
    -
    - ); -} diff --git a/packages/web/src/router.tsx b/packages/web/src/router.tsx index a556bca4..c44e9b8e 100644 --- a/packages/web/src/router.tsx +++ b/packages/web/src/router.tsx @@ -9,7 +9,7 @@ import { } from '@/agent-loaders'; import {getSignInMethods, type SignInMethods} from '@/client/auth-client'; import {getMe, isNotFoundError} from '@/client/me-client'; -import {getOrg, listOrgs} from '@/client/orgs-client'; +import {getOrg, listOrgs, type Org} from '@/client/orgs-client'; import {HumanSide} from '@/components/human-side'; import {RouteError} from '@/components/route-error'; import {sanitizeCallbackUrl} from '@/lib/callback-url'; @@ -37,6 +37,7 @@ import {AddConnectionPage} from '@/pages/add-connection-page'; import {AddDatabasePage} from '@/pages/add-database-page'; import {AddEmailPage} from '@/pages/add-email-page'; import {AddMcpPage} from '@/pages/add-mcp-page'; +import {AccountPage} from '@/pages/account-page'; import {AgentPage} from '@/pages/agent-page'; import {AgentSetupPage} from '@/pages/agent-setup-page'; import {AgentsPage} from '@/pages/agents-page'; @@ -56,20 +57,26 @@ import {InvitePage} from '@/pages/invite-page'; import {LoginPage} from '@/pages/login-page'; import {MembersPage} from '@/pages/members-page'; import {NewAgentPage} from '@/pages/new-agent-page'; -import {SettingsPage} from '@/pages/settings-page'; +import {OrgSettingsPage} from '@/pages/org-settings-page'; import {VaultPage} from '@/pages/vault-page'; import {WelcomeConnectionPage, WelcomePage} from '@/pages/welcome-page'; -// The app's root: no organization in the address, so the person's first opens, the one made at -// sign-up until they join or make another. Without a session, /login. A person in no organization -// at all, which deleting one guards against, is told so rather than sent to /login and back. -async function rootLoader() { - const {orgs} = unwrapLoaderResult(await listOrgs()); +// The person's first organization, the one made at sign-up until they join or make another. A +// person in no organization at all, which deleting one guards against, is told so rather than sent +// to /login and back. +function firstOrg(orgs: Org[]): Org { const first = orgs[0]; if (!first) { throw new Error('You are in no organization. Ask someone to invite you to theirs.'); } - throw redirect(`/${first.slug}`); + return first; +} + +// The app's root: no organization in the address, so the person's first opens. Without a session, +// /login. +async function rootLoader() { + const {orgs} = unwrapLoaderResult(await listOrgs()); + throw redirect(`/${firstOrg(orgs).slug}`); } // The app's pages before every page was one organization's: a saved link to one, or to a page @@ -111,12 +118,11 @@ async function unknownLoader({request}: {request: Request}) { throw redirect(legacyRedirect(url, first)); } -// The human side's guard, on every page in an organization (`/:org/…`): no session sends the user -// to /login, and back here after; an organization that isn't theirs sends them to their first, an -// old single-page link (`/connections`) to that page there; one that has not been -// through the welcome flow goes there first. With who is signed in, the +// The human side's guard, on every page in an organization (`/:org/…`) and the person's account: no session sends the user to /login, and back here after; an organization that isn't +// theirs sends them to their first, an old single-page link (`/connections`) to that page there; +// one that has not been through the welcome flow goes there first. With who is signed in, the // organization and the organizations they are in, for the switcher and every page that says which -// is open. +// is open. The account names none, so the sidebar shows their first. async function humanLoader({ params, request, @@ -124,7 +130,14 @@ async function humanLoader({ params: {org?: string}; request: Request; }): Promise { - const slug = params.org ?? ''; + if (params.org === undefined) { + const [me, orgs] = await Promise.all([getMe(), listOrgs()]); + const signedIn = unwrapLoaderResult(me); + const all = unwrapLoaderResult(orgs).orgs; + return {...signedIn, org: firstOrg(all), orgs: all}; + } + + const slug = params.org; const [me, org, orgs] = await Promise.all([getMe(), getOrg(slug), listOrgs()]); const signedIn = unwrapLoaderResult(me); if (org.isErr() && isNotFoundError(org.error)) { @@ -219,10 +232,9 @@ export const router = createBrowserRouter([ errorElement: , }, // Everything in an organization, under its slug: the address says which, so a link, a bookmark - // or another tab stands on its own. + // or another tab stands on its own. The person's account, in none, shares its frame. { id: 'human', - path: '/:org', loader: humanLoader, // The session is checked on arrival, not on every click: only another organization, or a // page revalidating after a change (the address the same), as renaming an organization or @@ -232,7 +244,8 @@ export const router = createBrowserRouter([ element: , errorElement: , children: [ - {index: true, loader: homeLoader, element: }, + {path: '/account', element: }, + {path: '/:org', loader: homeLoader, element: }, {path: '/:org/connections', loader: connectionsLoader, element: }, {path: '/:org/connections/new', loader: addConnectionLoader, element: }, {path: '/:org/connections/new/email', element: }, @@ -248,7 +261,7 @@ export const router = createBrowserRouter([ {path: '/:org/agents/:id/setup', loader: agentSetupLoader, element: }, {path: '/:org/activity', loader: activityLoader, element: }, {path: '/:org/members', loader: membersLoader, element: }, - {path: '/:org/settings', element: }, + {path: '/:org/settings', element: }, ], }, {