Simplify Admin > Connectors

- One control for members' own MCP servers: the custom MCP server row's
  switch (it sets both the policy and the instance switch); the separate
  top-level switch is gone.
- Connectors that only sync content show "No tools" instead of a
  sharing policy, and the policies read "The sharer decides per share",
  "Always the sharer's account", "Always each person's own account".
- A connector that needs server settings can't be switched on until they
  exist; a tooltip says why.
- On phones the table becomes a list ("On · 2 connections · …") and each
  connector's controls open in a sheet.
This commit is contained in:
arc53-machine committed 2026-09-28 21:52:18 +01:00
1 parent 23229181f1
commit df70869dc7
5 files changed
+304 -110

No files matched your search

+3 -3
View File
@@ -50,11 +50,11 @@ Tool calls name the service that ran them: "Searched Notion", "Read from Google
Admins manage connectors in **Admin > Connectors**. It lists every connector with its status, how many connections use it, and:
- **Enabled** turns a connector off for everyone. Members no longer see it (except to manage a connection they already have), its tools stop working, and its sources stop syncing until you turn it back on. Existing connections are kept.
- **Shared tools use** is **Let owners choose** (default), **Always the owner's account** or **Always each member's own account**.
- **Allow custom MCP servers** controls whether members can add their own MCP servers. Presets are enabled one by one.
- **Shared tools use** is **The sharer decides per share** (default), **Always the sharer's account** or **Always each person's own account**. Connectors that only sync content have no tools and show **No tools**.
- The **MCP server** row decides whether members can add their own MCP servers. Presets are switched one by one.
- **Setup guide** on an OAuth connector shows the redirect URI to register and which server settings are still missing.
A connector that still needs server settings starts turned off and is hidden from members. It turns on once its settings are present, unless you switched it off; switching it on before then has no effect for members.
A connector that still needs server settings starts turned off and is hidden from members; its switch stays disabled until the settings are present. It turns on once they are, unless you switched it off. On a phone, the page lists the connectors and opens each one's controls in a panel.
### Encryption key
+1
View File
@@ -70,6 +70,7 @@ class AdminConnectorsResource(Resource):
"category": definition.category,
"publisher": definition.publisher,
"auth_kind": definition.auth_kind,
"capabilities": list(definition.capabilities),
"enabled": service.connector_is_enabled(policies, definition.key),
"credential_mode": policy.get("credential_mode", "choose"),
"configured": definition.configured,
+112 -35
View File
@@ -30,6 +30,7 @@ const connector = (overrides: Record<string, unknown> = {}) => ({
icon: 'google-drive',
publisher: 'built_in',
auth_kind: 'oauth',
capabilities: ['sync'],
enabled: true,
credential_mode: 'choose',
configured: false,
@@ -43,6 +44,29 @@ const connector = (overrides: Record<string, unknown> = {}) => ({
...overrides,
});
const MCP_ROW = connector({
key: 'custom_mcp',
name: 'MCP server',
icon: 'mcp',
publisher: 'custom',
auth_kind: 'mcp',
capabilities: ['read', 'write'],
configured: true,
required_settings: [],
connection_count: 0,
});
const NOTION = {
key: 'mcp_notion',
name: 'Notion',
icon: 'notion',
publisher: 'preset',
auth_kind: 'mcp_oauth',
capabilities: ['read', 'write'],
configured: true,
required_settings: [],
connection_count: 0,
};
const payload = (overrides: Record<string, unknown> = {}) => ({
success: true,
connectors: [
@@ -53,10 +77,12 @@ const payload = (overrides: Record<string, unknown> = {}) => ({
icon: 'notion',
publisher: 'preset',
auth_kind: 'mcp_oauth',
capabilities: ['read', 'write'],
configured: true,
required_settings: [],
connection_count: 0,
}),
MCP_ROW,
],
allow_custom_mcp: true,
default_encryption_key: false,
@@ -106,7 +132,7 @@ describe('Admin Connectors', () => {
getAdmin.mockResolvedValue(payload());
await render();
const rows = Array.from(container.querySelectorAll('tbody tr'));
expect(rows).toHaveLength(2);
expect(rows).toHaveLength(3);
expect(rows[0].textContent).toContain('Google Drive');
expect(rows[0].textContent).toContain('Needs setup');
expect(rows[0].textContent).toContain('Setup guide');
@@ -130,24 +156,74 @@ describe('Admin Connectors', () => {
it('saves a connector toggle as a policy', async () => {
getAdmin.mockResolvedValue(payload());
updateAdmin.mockResolvedValue(
payload({
connectors: [connector({ enabled: false })],
}),
payload({ connectors: [connector({ ...NOTION, enabled: false })] }),
);
await render();
const toggle = container.querySelector<HTMLButtonElement>(
'[aria-label="Google Drive enabled"]',
)!;
await act(async () => toggle.click());
const toggle = () =>
container.querySelector<HTMLButtonElement>(
'table [aria-label="Notion enabled"]',
)!;
await act(async () => toggle().click());
expect(updateAdmin).toHaveBeenCalledWith(
{ policies: { google_drive: { enabled: false } } },
{ policies: { mcp_notion: { enabled: false } } },
null,
);
expect(toggle().getAttribute('aria-checked')).toBe('false');
});
it('keeps a connector that needs setup off, and says why', async () => {
getAdmin.mockResolvedValue(payload());
await render();
const drive = container.querySelector<HTMLButtonElement>(
'table [aria-label="Google Drive enabled"]',
)!;
expect(drive.disabled).toBe(true);
});
it('shows no sharing policy for a sync-only connector', async () => {
getAdmin.mockResolvedValue(payload());
await render();
const [drive, notion] = Array.from(container.querySelectorAll('tbody tr'));
expect(drive.textContent).toContain('No tools');
expect(
container
.querySelector('[aria-label="Google Drive enabled"]')
?.getAttribute('aria-checked'),
).toBe('false');
notion.querySelector('[aria-label="Notion sharing policy"]'),
).not.toBeNull();
});
it('turns custom MCP servers off from their own row', async () => {
getAdmin.mockResolvedValue(payload());
updateAdmin.mockResolvedValue(payload({ allow_custom_mcp: false }));
await render();
const mcp = () =>
container.querySelector<HTMLButtonElement>(
'table [aria-label="MCP server enabled"]',
)!;
await act(async () => mcp().click());
expect(updateAdmin).toHaveBeenCalledWith(
{ allow_custom_mcp: false, policies: { custom_mcp: { enabled: false } } },
null,
);
expect(mcp().getAttribute('aria-checked')).toBe('false');
expect(container.querySelector('#allow-custom-mcp')).toBeNull();
});
it('lists connectors on phones and opens their controls in a sheet', async () => {
getAdmin.mockResolvedValue(payload());
await render();
const rows = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-slot="list-row"] button',
),
);
expect(rows).toHaveLength(3);
expect(rows[0].textContent).toContain(
'Needs setup · 3 connections · No tools',
);
await act(async () => rows[1].click());
expect(
document.body.querySelector('[data-slot="sheet-content"]'),
).not.toBeNull();
expect(document.body.textContent).toContain('Shared tools use');
});
it('reports a failed save in a toast and keeps the page', async () => {
@@ -155,14 +231,21 @@ describe('Admin Connectors', () => {
updateAdmin.mockResolvedValue({ success: false });
await render();
await act(async () =>
container.querySelector<HTMLButtonElement>('#allow-custom-mcp')!.click(),
container
.querySelector<HTMLButtonElement>(
'table [aria-label="Notion enabled"]',
)!
.click(),
);
expect(updateAdmin).toHaveBeenCalledWith(
{ policies: { mcp_notion: { enabled: false } } },
null,
);
expect(updateAdmin).toHaveBeenCalledWith({ allow_custom_mcp: false }, null);
expect(selectActionToast(store.getState())).toMatchObject({
variant: 'destructive',
message: 'Could not save the change.',
});
expect(container.querySelectorAll('tbody tr')).toHaveLength(2);
expect(container.querySelectorAll('tbody tr')).toHaveLength(3);
});
it('saves one change at a time so a late response cannot win', async () => {
@@ -172,34 +255,28 @@ describe('Admin Connectors', () => {
() => new Promise((resolve) => pending.push(resolve)),
);
await render();
const toggle = () =>
const notion = () =>
container.querySelector<HTMLButtonElement>(
'[aria-label="Google Drive enabled"]',
'table [aria-label="Notion enabled"]',
)!;
await act(async () => toggle().click());
await act(async () =>
container.querySelector<HTMLButtonElement>('#allow-custom-mcp')!.click(),
);
const mcp = () =>
container.querySelector<HTMLButtonElement>(
'table [aria-label="MCP server enabled"]',
)!;
await act(async () => notion().click());
await act(async () => mcp().click());
// The second save waits for the first.
expect(updateAdmin).toHaveBeenCalledTimes(1);
await act(async () =>
pending[0](payload({ connectors: [connector({ enabled: false })] })),
);
const off = connector({ ...NOTION, enabled: false });
await act(async () => pending[0](payload({ connectors: [off, MCP_ROW] })));
expect(updateAdmin).toHaveBeenCalledTimes(2);
await act(async () =>
pending[1](
payload({
connectors: [connector({ enabled: false })],
allow_custom_mcp: false,
}),
payload({ connectors: [off, MCP_ROW], allow_custom_mcp: false }),
),
);
expect(toggle().getAttribute('aria-checked')).toBe('false');
expect(
container
.querySelector('#allow-custom-mcp')!
.getAttribute('aria-checked'),
).toBe('false');
expect(notion().getAttribute('aria-checked')).toBe('false');
expect(mcp().getAttribute('aria-checked')).toBe('false');
});
it('offers a retry when loading fails', async () => {
+186 -72
View File
@@ -9,6 +9,14 @@ import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import { Badge } from '../components/ui/badge';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
import { FormField } from '../components/ui/form-field';
import { ListRow, ListRows } from '../components/ui/list-row';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from '../components/ui/sheet';
import {
DescriptionItem,
DescriptionList,
@@ -34,6 +42,11 @@ import {
TableHeader,
TableRow,
} from '../components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '../components/ui/tooltip';
import ConnectorIcon from '../connectors/ConnectorIcon';
import { showActionToast } from '../notifications/actionToastSlice';
import { selectToken } from '../preferences/preferenceSlice';
@@ -47,6 +60,7 @@ type AdminConnector = {
icon: string;
publisher: 'built_in' | 'preset' | 'custom';
auth_kind: string;
capabilities: string[];
enabled: boolean;
credential_mode: Policy;
configured: boolean;
@@ -66,11 +80,14 @@ type AdminConnectorsData = {
};
const POLICY_LABELS: Record<Policy, string> = {
choose: 'Let owners choose',
member: "Always each member's own account",
owner: "Always the owner's account",
choose: 'The sharer decides per share',
member: "Always each person's own account",
owner: "Always the sharer's account",
};
const hasTools = (connector: AdminConnector) =>
connector.capabilities.some((capability) => capability !== 'sync');
function CodeRow({ value }: { value: string }) {
return (
<Card variant="filled" padding="sm" className="flex-row items-start gap-2">
@@ -161,6 +178,7 @@ export default function Connectors() {
const [data, setData] = useState<AdminConnectorsData | null>(null);
const [loading, setLoading] = useState(true);
const [guide, setGuide] = useState<AdminConnector | null>(null);
const [detailKey, setDetailKey] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -207,6 +225,94 @@ export default function Connectors() {
if (!data?.success)
return <LoadError message="Failed to load connectors." onRetry={load} />;
// The sheet (phones) always shows the connector's latest saved state.
const detail = data.connectors.find((c) => c.key === detailKey) ?? null;
// The custom MCP row is the one switch for members' own MCP servers.
const isCustomMcp = (connector: AdminConnector) =>
connector.key === 'custom_mcp';
const enabledOf = (connector: AdminConnector) =>
connector.enabled && (!isCustomMcp(connector) || data.allow_custom_mcp);
const setEnabled = (connector: AdminConnector, on: boolean) =>
save(
isCustomMcp(connector)
? { allow_custom_mcp: on, policies: { custom_mcp: { enabled: on } } }
: { policies: { [connector.key]: { enabled: on } } },
);
const statusBadge = (connector: AdminConnector) => (
<Badge variant={connector.configured ? 'success' : 'warning'}>
{connector.configured ? 'Ready' : 'Needs setup'}
</Badge>
);
const summary = (connector: AdminConnector) =>
[
!connector.configured
? 'Needs setup'
: enabledOf(connector)
? 'On'
: 'Off',
`${fmtNumber(connector.connection_count)} ${
connector.connection_count === 1 ? 'connection' : 'connections'
}`,
hasTools(connector)
? POLICY_LABELS[connector.credential_mode]
: 'No tools',
].join(' · ');
const enabledSwitch = (connector: AdminConnector) => {
const control = (
<Switch
checked={enabledOf(connector)}
disabled={!connector.configured}
aria-label={`${connector.name} enabled`}
onCheckedChange={(checked) => setEnabled(connector, checked === true)}
/>
);
// Off until its server settings exist: switching it on would do nothing.
if (connector.configured) return control;
return (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} className="inline-flex w-fit">
{control}
</span>
</TooltipTrigger>
<TooltipContent>Add its server settings first</TooltipContent>
</Tooltip>
);
};
const policyControl = (connector: AdminConnector, fullWidth = false) =>
hasTools(connector) ? (
<Select
value={connector.credential_mode}
onValueChange={(value) =>
save({
policies: { [connector.key]: { credential_mode: value as Policy } },
})
}
>
<SelectTrigger
size="sm"
className={fullWidth ? 'w-full' : 'w-60'}
aria-label={`${connector.name} sharing policy`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(POLICY_LABELS) as Policy[]).map((policy) => (
<SelectItem key={policy} value={policy}>
{POLICY_LABELS[policy]}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-muted-foreground text-sm">No tools</span>
);
return (
<div className="flex flex-col gap-8">
<PageToolbar intro="Choose which connectors members can use and whose account a shared tool runs with. A connector that still needs server settings starts turned off and is hidden from members; it turns on once its settings are in place, unless you switch it off." />
@@ -229,22 +335,6 @@ export default function Connectors() {
</Alert>
)}
<SettingRows>
<SettingRow
label="Allow custom MCP servers"
description="Members can connect any remote MCP server. Presets are controlled one by one below."
htmlFor="allow-custom-mcp"
>
<Switch
id="allow-custom-mcp"
checked={data.allow_custom_mcp}
onCheckedChange={(checked) =>
save({ allow_custom_mcp: checked === true })
}
/>
</SettingRow>
</SettingRows>
<section className="flex flex-col gap-3">
<SectionHeader
title="Redirect URIs"
@@ -265,8 +355,37 @@ export default function Connectors() {
</section>
<section className="flex flex-col gap-3">
<SectionHeader title="Connectors" />
<TableContainer>
<SectionHeader
title="Connectors"
description="The custom MCP server row decides whether members can add their own MCP servers; presets are switched one by one."
/>
{/* Phones: a list; each row opens the connector's controls. */}
<Card padding="none" className="overflow-hidden md:hidden">
<ListRows>
{data.connectors.map((connector) => (
<ListRow
key={connector.key}
interactive
asChild
leading={
<ConnectorIcon
icon={connector.icon}
className="size-5 shrink-0"
/>
}
title={connector.name}
description={summary(connector)}
trailing={statusBadge(connector)}
>
<button
type="button"
onClick={() => setDetailKey(connector.key)}
/>
</ListRow>
))}
</ListRows>
</Card>
<TableContainer className="hidden md:block">
<Table>
<TableHead>
<TableRow>
@@ -297,60 +416,12 @@ export default function Connectors() {
)}
</span>
</TableCell>
<TableCell>
<Badge
variant={connector.configured ? 'success' : 'warning'}
>
{connector.configured ? 'Ready' : 'Needs setup'}
</Badge>
</TableCell>
<TableCell>{statusBadge(connector)}</TableCell>
<TableCell align="right" className="tabular-nums">
{fmtNumber(connector.connection_count)}
</TableCell>
<TableCell>
<Switch
checked={connector.enabled}
aria-label={`${connector.name} enabled`}
onCheckedChange={(checked) =>
save({
policies: {
[connector.key]: { enabled: checked === true },
},
})
}
/>
</TableCell>
<TableCell>
<Select
value={connector.credential_mode}
onValueChange={(value) =>
save({
policies: {
[connector.key]: {
credential_mode: value as Policy,
},
},
})
}
>
<SelectTrigger
size="sm"
className="w-60"
aria-label={`${connector.name} sharing policy`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(POLICY_LABELS) as Policy[]).map(
(policy) => (
<SelectItem key={policy} value={policy}>
{POLICY_LABELS[policy]}
</SelectItem>
),
)}
</SelectContent>
</Select>
</TableCell>
<TableCell>{enabledSwitch(connector)}</TableCell>
<TableCell>{policyControl(connector)}</TableCell>
<TableCell align="right">
{connector.required_settings.length > 0 && (
<Button
@@ -370,6 +441,49 @@ export default function Connectors() {
</TableContainer>
</section>
{detail && (
<Sheet open onOpenChange={(open) => !open && setDetailKey(null)}>
<SheetContent side="right" size="detail" closeLabel="Close">
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center gap-3 pr-12">
<ConnectorIcon icon={detail.icon} className="size-7" />
<SheetTitle className="truncate">{detail.name}</SheetTitle>
</div>
<SheetDescription>{summary(detail)}</SheetDescription>
<SettingRows>
<SettingRow
label="Enabled"
description={
detail.configured
? 'Members can connect and use it.'
: 'Add its server settings first (Setup guide).'
}
>
{enabledSwitch(detail)}
</SettingRow>
</SettingRows>
{hasTools(detail) && (
<FormField label="Shared tools use">
{policyControl(detail, true)}
</FormField>
)}
{detail.required_settings.length > 0 && (
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
className="w-fit"
onClick={() => setGuide(detail)}
>
Setup guide
</Button>
)}
</div>
</SheetContent>
</Sheet>
)}
{guide && (
<SetupGuide
connector={guide}
@@ -108,6 +108,8 @@ class TestAdminPolicies:
{"name": "GOOGLE_CLIENT_ID", "set": True}, {"name": "GOOGLE_CLIENT_SECRET", "set": False},
]
assert drive["connection_count"] == 1
# Sync-only: the page shows no sharing policy for it.
assert drive["capabilities"] == ["sync"]
assert "secret-client-id" not in json.dumps(payload)
assert payload["allow_custom_mcp"] is True