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
Original file line number Diff line number Diff line change
@@ -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';
2 changes: 1 addition & 1 deletion packages/db/src/organization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
});
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 4 additions & 2 deletions packages/docs/content/organizations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/map/src/button-edges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down
35 changes: 35 additions & 0 deletions packages/web/src/components/danger-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className="overflow-hidden rounded-xl border border-destructive/40">
<div className="grid gap-1 p-4">
<h2 className="text-sm font-medium">{title}</h2>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<div className="flex justify-end border-t border-destructive/40 bg-destructive/5 px-4 py-3">
<Button
className="bg-destructive text-white hover:bg-destructive/90"
disabled={disabled}
onClick={onClick}
>
{button}
</Button>
</div>
</section>
);
}
15 changes: 9 additions & 6 deletions packages/web/src/components/nav.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
BotIcon,
CircleUserIcon,
HomeIcon,
LibraryIcon,
LogOutIcon,
Expand Down Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -86,12 +89,12 @@ export function AppSidebar() {
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith(`${home}/settings`)}
tooltip="Settings"
render={<Link to={`${home}/settings`} onClick={() => setOpenMobile(false)} />}
isActive={pathname === '/account'}
tooltip="Account"
render={<Link to="/account" onClick={() => setOpenMobile(false)} />}
>
<SettingsIcon />
<span>Settings</span>
<CircleUserIcon />
<span>Account</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
Expand Down
20 changes: 20 additions & 0 deletions packages/web/src/components/setting-row.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li className="bleed flex h-10 min-w-0 items-center gap-3 text-sm">
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
{action}
</li>
);
}
91 changes: 91 additions & 0 deletions packages/web/src/pages/account-page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AppShell title={<PageTitle>Account</PageTitle>}>
<div className="flex flex-col gap-8">
<Section title="Profile">
<RowList>
<SettingRow label="Email">{human.user.email}</SettingRow>
{human.user.name && <SettingRow label="Name">{human.user.name}</SettingRow>}
</RowList>
</Section>
<Section title="Organizations" detail={human.orgs.length}>
<RowList>
{human.orgs.map((org) => (
<li key={org.id} className="bleed flex h-10 min-w-0 items-center gap-3 text-sm">
<OrgTile org={org} />
<Link
to={`/${org.slug}/settings`}
className="min-w-0 flex-1 truncate underline-offset-4 hover:underline"
>
{org.name}
</Link>
<span className="shrink-0 text-muted-foreground">
{org.role === 'owner' ? 'Owner' : 'Member'}
</span>
</li>
))}
</RowList>
</Section>
<DangerCard
title="Delete account"
description={`${deletedWith(shared)} You leave the organizations you don't own. Every connection deleted is first invalidated at the provider (where possible), then deleted with its vault, agents and activity. This cannot be undone.`}
button="Delete account"
onClick={() => setDeleting(true)}
/>
</div>
<TypeToConfirmDialog
key={String(deleting)}
open={deleting}
title="Delete your account?"
description={`${deletedWith(shared)} You leave the others. Every connection deleted is first invalidated at the provider (where possible), then deleted with its vault, agents and activity. This cannot be undone.`}
word={human.user.email}
confirmLabel="Delete account"
onClose={() => 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;
}}
/>
</AppShell>
);
}

// 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(', ')}.`;
}
147 changes: 147 additions & 0 deletions packages/web/src/pages/org-settings-page.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<AppShell title={<PageTitle detail={organization.name}>Settings</PageTitle>}>
<div className="flex flex-col gap-8">
{error && <p className="text-sm text-destructive">{error}</p>}
<Section title="Organization">
<RowList>
<SettingRow
label="Name"
action={
owner && (
<IconButton label="Rename organization" onClick={() => setRenaming(true)}>
<PencilIcon />
</IconButton>
)
}
>
<span className="flex items-center gap-2">
<OrgTile org={organization} />
{organization.name}
</span>
</SettingRow>
<SettingRow label="Your role">{owner ? 'Owner' : 'Member'}</SettingRow>
<SettingRow label="Members">
<Link to={`/${organization.slug}/members`} className="underline underline-offset-4">
{organization.memberCount} {organization.memberCount === 1 ? 'member' : 'members'}
</Link>
</SettingRow>
</RowList>
</Section>
{owner && (
<DangerCard
title={`Delete ${organization.name}`}
description={
lastOwned
? 'This is the only organization you own. Create another first, or delete your account instead.'
: 'Its connections are first invalidated at the provider (where possible), then deleted with its vault, agents and activity. Everyone in it loses it. This cannot be undone.'
}
button="Delete organization"
disabled={lastOwned}
onClick={() => setDeleting(true)}
/>
)}
{!owner && (
<DangerCard
title={`Leave ${organization.name}`}
description="What you connected for yourself there is disconnected and your own agents there are deleted; the shared connections and agents stay. Its owner can invite you again."
button="Leave organization"
onClick={() => setLeaving(true)}
/>
)}
</div>
{renaming && (
<RenameDialog
key={organization.id}
open
title={`Rename ${organization.name}`}
description="What everyone in it sees, in the switcher and on Members."
initial={organization.name}
allowEmpty={false}
onClose={() => setRenaming(false)}
onSave={async (name) => {
const result = await renameOrg(organization.slug, name);
if (result.isErr()) {
return describeApiError(result.error);
}
await revalidator.revalidate();
return null;
}}
/>
)}
<TypeToConfirmDialog
key={String(deleting)}
open={deleting}
title={`Delete ${organization.name}?`}
description="Its connections are invalidated at the provider and deleted, with its vault, agents and activity. Everyone in it loses it. This cannot be undone."
word={organization.name}
confirmLabel="Delete organization"
onClose={() => 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;
}}
/>
<ConfirmDialog
open={leaving}
title={`Leave ${organization.name}?`}
description={`You lose ${organization.name} at once: its connections, agents and activity are no longer yours to see or use. Its owner can invite you again.`}
confirmLabel="Leave"
destructive
onClose={() => 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)}`);
}}
/>
</AppShell>
);
}
Loading
Loading