diff --git a/apps/web/src/app/(app)/settings/mcp-access/page.module.css b/apps/web/src/app/(app)/settings/mcp-access/page.module.css new file mode 100644 index 00000000..c943c12f --- /dev/null +++ b/apps/web/src/app/(app)/settings/mcp-access/page.module.css @@ -0,0 +1,98 @@ +.page { + /* Local contrast: the workspace ghost border is too faint for form controls. */ + --border-default: color-mix(in srgb, var(--text-primary) 17%, transparent); + --connection-action: #7056dc; + color: var(--text-primary); +} + +.page :is(button, a, input, select, summary, pre):focus-visible { + outline: 2px solid var(--primary); + outline-offset: 3px; +} + +.page :is(button, a, summary) { + transition: + background-color 140ms ease, + border-color 140ms ease, + box-shadow 140ms ease; +} + +.page button:not(:disabled):hover { + box-shadow: inset 0 0 0 100px color-mix(in srgb, var(--text-primary) 6%, transparent); +} + +.page button:disabled { + cursor: not-allowed; +} + +.page :is(input:not([type='checkbox']), select) { + background-color: var(--surface-container-low); + color: var(--text-primary); + border-color: color-mix(in srgb, var(--text-primary) 30%, transparent); +} + +.page input::placeholder { + color: var(--text-tertiary); + opacity: 1; +} + +.page input[type='checkbox'] { + width: 1rem; + height: 1rem; + accent-color: var(--accent); +} + +.page :is(summary, p, code, strong) { + overflow-wrap: anywhere; +} + +.page summary { + border-radius: 6px; +} + +.page summary:hover { + background-color: color-mix(in srgb, var(--text-primary) 4%, transparent); +} + +.connectionList { + background: var(--surface-container-low); + overflow: clip; +} + +.connectionList > details + details { + border-color: var(--border-default); +} + +.connectionList > details[open] { + background: color-mix(in srgb, var(--surface-container) 65%, var(--surface)); +} + +.connectionList > details > summary:focus-visible { + outline-offset: -2px; +} + +.setup { + width: 100%; + max-width: 44rem; +} + +.setup nav { + padding: 1rem; + border: 1px solid var(--border-default); + border-radius: 0.75rem; + background: var(--surface-container-low); +} + +.setup nav [aria-current='step'] > span:first-child { + background: var(--accent-muted); +} + +.clientChoice { + min-height: 4.5rem; +} + +@media (prefers-reduced-motion: reduce) { + .page :is(button, a, summary) { + transition: none; + } +} diff --git a/apps/web/src/app/(app)/settings/mcp-access/page.tsx b/apps/web/src/app/(app)/settings/mcp-access/page.tsx index 0f9c76c3..015b949e 100644 --- a/apps/web/src/app/(app)/settings/mcp-access/page.tsx +++ b/apps/web/src/app/(app)/settings/mcp-access/page.tsx @@ -1,31 +1,12 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; -import { - Activity, - Bot, - Check, - ChevronDown, - ChevronRight, - Code2, - Copy, - FileText, - Globe2, - History, - KeyRound, - Loader2, - Plug, - Settings2, - ShieldCheck, - Sparkles, - Terminal, - Trash2, - Wrench, -} from 'lucide-react'; +import { Bot, Check, ChevronDown, Code2, Copy, Globe2, KeyRound, Plug, Wrench } from 'lucide-react'; import { api } from '@/lib/api'; -import { PageHeader } from '@/components/page-header'; import { useSetPageContext } from '@/components/app-header-context'; +import { SettingsSteps } from '@/components/settings-steps'; +import styles from './page.module.css'; type McpToken = { id: string; @@ -38,30 +19,19 @@ type McpToken = { }; const READ_SCOPES = ['read:workspace', 'read:wiki', 'read:tasks', 'read:messages', 'read:calendar', 'read:modules']; -const WRITE_SCOPES = ['write:tasks', 'write:messages', 'write:wiki', 'write:calendar', 'write:modules', 'write:workspace']; +const WRITE_SCOPES = [ + 'write:tasks', + 'write:messages', + 'write:wiki', + 'write:calendar', + 'write:modules', + 'write:workspace', +]; const COLLABORATE_SCOPES = [...READ_SCOPES, 'write:tasks', 'write:messages', 'write:wiki', 'write:modules']; const ALL_SCOPES = [...READ_SCOPES, ...WRITE_SCOPES]; const APP_SCOPES = ['read:apps', 'invoke:apps', 'read:app-runs']; const AVAILABLE_SCOPES = [...ALL_SCOPES, ...APP_SCOPES]; -const SCOPE_LABELS: Record = { - 'read:workspace': 'Workspace map, teammates, projects, receipts, and activity context', - 'read:wiki': 'Company, channel, and personal memory packets', - 'read:tasks': 'Task lists, task detail, comments, and progress', - 'read:messages': 'Visible spaces, threads, unread work, and chat search', - 'read:calendar': 'Native and ICS calendar context', - 'read:modules': 'Installed module schemas and records, such as the Contacts Directory', - 'read:apps': 'Installed App identities, reviewed grants, bindings, and health', - 'invoke:apps': 'Discover, prepare, and invoke reviewed App actions as you', - 'read:app-runs': 'Inspect authorized App Run status, safe previews, and retained results', - 'write:tasks': 'Create, update, transition, and comment on tasks', - 'write:messages': 'Post messages into spaces and DMs you can access', - 'write:wiki': 'Save or update wiki knowledge as you', - 'write:calendar': 'Create, update, and cancel your native Deft calendar events', - 'write:modules': 'Create, update, and archive records in enabled modules', - 'write:workspace': 'Manage your notes, inbox, approvals, projects, and agent operations', -}; - type ClientId = 'codex' | 'claude-code' | 'claude-desktop' | 'remote-web' | 'headless' | 'custom' | 'agent-employee'; type AccessPreset = 'read' | 'work' | 'operate' | 'custom'; @@ -80,7 +50,8 @@ const CLIENT_OPTIONS: ClientOption[] = [ id: 'codex', name: 'Codex', fit: 'Recommended token setup', - detail: 'Best for owner-operator workflows: triage messages, read tasks, write wiki, post updates, and leave receipts.', + detail: + 'Best for owner-operator workflows: triage messages, read tasks, write wiki, post updates, and leave receipts.', setupKind: 'token', defaultPreset: 'work', tokenName: 'Codex', @@ -98,7 +69,8 @@ const CLIENT_OPTIONS: ClientOption[] = [ id: 'claude-desktop', name: 'Claude / Claude Desktop', fit: 'Remote OAuth connector', - detail: 'Add Deft from Claude settings, not inside a chat. Claude connects from Anthropic cloud and authenticates through OAuth.', + detail: + 'Add Deft from Claude settings, not inside a chat. Claude connects from Anthropic cloud and authenticates through OAuth.', setupKind: 'oauth', defaultPreset: 'read', tokenName: 'Claude connector', @@ -107,7 +79,8 @@ const CLIENT_OPTIONS: ClientOption[] = [ id: 'remote-web', name: 'ChatGPT / hosted AI apps', fit: 'Plan-dependent access', - detail: 'ChatGPT Pro currently supports read/fetch. Full read/write MCP requires an eligible Business or Enterprise/Edu workspace using Developer Mode.', + detail: + 'ChatGPT Pro currently supports read/fetch. Full read/write MCP requires an eligible Business or Enterprise/Edu workspace using Developer Mode.', setupKind: 'oauth', defaultPreset: 'read', tokenName: 'Remote AI app', @@ -116,7 +89,8 @@ const CLIENT_OPTIONS: ClientOption[] = [ id: 'headless', name: 'Headless / automation', fit: 'Full workspace operation', - detail: 'For scripts and AI clients that operate notes, inbox, approvals, projects, calendar, and agent state without opening Deft.', + detail: + 'For scripts and AI clients that operate notes, inbox, approvals, projects, calendar, and agent state without opening Deft.', setupKind: 'token', defaultPreset: 'operate', tokenName: 'Headless operator', @@ -190,16 +164,6 @@ const READ_TEST_PROMPTS = [ 'Show me which Deft capabilities and tools this connection can use.', ]; -const COLLABORATE_TEST_PROMPTS = [ - 'Create a follow-up task from this discussion, post an update, and show me the receipt.', - 'Update the relevant wiki page with this decision without creating a duplicate.', -]; - -const OPERATE_TEST_PROMPTS = [ - 'Review my inbox, approvals, tasks, and calendar, then give me one prioritized operating brief.', - 'Create a calendar event for the agreed review, add a note with the agenda, and show me the receipts.', -]; - type RemoteReadiness = { public_url: string; mcp_endpoint_url: string; @@ -289,7 +253,7 @@ function actionResult(action: OAuthAuditAction): Record | null if (typeof text !== 'string') return null; try { const parsed = JSON.parse(text); - return parsed && typeof parsed === 'object' ? parsed as Record : null; + return parsed && typeof parsed === 'object' ? (parsed as Record) : null; } catch { return null; } @@ -330,20 +294,25 @@ function actionHref(action: OAuthAuditAction) { const toolName = typeof action.metadata?.tool_name === 'string' ? action.metadata.tool_name : null; const result = actionResult(action); if (!result) return null; - if ((toolName === 'task_create' || toolName === 'task_transition' || toolName === 'task_update' || toolName === 'comment_on_task') && typeof result.id === 'string') { + if ( + (toolName === 'task_create' || + toolName === 'task_transition' || + toolName === 'task_update' || + toolName === 'comment_on_task') && + typeof result.id === 'string' + ) { return `/tasks?task=${encodeURIComponent(result.id)}`; } - if ((toolName === 'message_post' || toolName === 'send_message') && typeof result.id === 'string' && typeof result.space_id === 'string') { + if ( + (toolName === 'message_post' || toolName === 'send_message') && + typeof result.id === 'string' && + typeof result.space_id === 'string' + ) { return `/chat?space=${encodeURIComponent(result.space_id)}&message=${encodeURIComponent(result.id)}`; } return null; } -function isStale(value: string | null | undefined) { - if (!value) return true; - return Date.now() - new Date(value).getTime() > 1000 * 60 * 60 * 24 * 14; -} - function clientById(id: ClientId) { return CLIENT_OPTIONS.find((client) => client.id === id) ?? CLIENT_OPTIONS[0]!; } @@ -357,7 +326,11 @@ function clientIcon(id: ClientId) { function RecentActionList({ actions }: { actions?: OAuthAuditAction[] }) { if (!actions?.length) { - return
No recent actions recorded.
; + return ( +
+ No recent actions recorded. +
+ ); } return (
@@ -365,18 +338,24 @@ function RecentActionList({ actions }: { actions?: OAuthAuditAction[] }) { const href = actionHref(action); const content = ( <> - + {actionTitle(action)} ({actionDetail(action)}) - {formatDate(action.created_at)} + + {formatDate(action.created_at)} + ); return href ? ( - + {content} ) : ( -
+
{content}
); @@ -385,11 +364,75 @@ function RecentActionList({ actions }: { actions?: OAuthAuditAction[] }) { ); } -function ScopePill({ scope }: { scope: string }) { +function permissionLabel(scope: string) { + const labels: Record = { + 'read:workspace': 'View workspace context, people and projects', + 'read:wiki': 'Read accessible knowledge and memory', + 'read:tasks': 'Read tasks, comments and progress', + 'read:messages': 'Read accessible conversations and unread messages', + 'read:calendar': 'Read your calendar context', + 'read:modules': 'Read accessible module records', + 'write:tasks': 'Create and update tasks and comments', + 'write:messages': 'Post messages in conversations you can access', + 'write:wiki': 'Create and update accessible knowledge', + 'write:calendar': 'Create, update and cancel your Deft events', + 'write:modules': 'Create, update and archive module records', + 'write:workspace': 'Manage your notes, inbox, approvals, projects and agent operations', + 'read:apps': 'View installed apps, permissions and health', + 'invoke:apps': 'Prepare and invoke reviewed app actions', + 'read:app-runs': 'Read authorized app run status and results', + }; + return labels[scope] ?? scope; +} + +function accessSummary(scopes: string[]) { + if (!scopes.length) return 'No permissions granted.'; + if (scopes.every((scope) => scope.startsWith('read:'))) return 'Read-only access within its granted permissions.'; + if (scopes.some((scope) => scope.startsWith('write:') || scope.startsWith('invoke:'))) + return 'Can read or make changes within its granted permissions.'; + return 'Access is defined by the permissions below.'; +} + +function PermissionDetails({ scopes }: { scopes: string[] }) { + return ( +
+ View {scopes.length} permissions +
    + {scopes.map((scope) => ( +
  • + {permissionLabel(scope)} + + {scope} + +
  • + ))} +
+
+ ); +} + +function CopyControl({ + label, + value, + copied, + onCopy, +}: { + label: string; + value: string; + copied: string | null; + onCopy: (label: string, value: string) => Promise; +}) { return ( - - {scope} - + ); } @@ -411,6 +454,42 @@ export default function McpAccessPage() { const [newToken, setNewToken] = useState(null); const [copied, setCopied] = useState(null); const [error, setError] = useState(null); + const [setupStep, setSetupStep] = useState(0); + const [tokenSaved, setTokenSaved] = useState(false); + const [issuedTokenId, setIssuedTokenId] = useState(null); + const [setupOpen, setSetupOpen] = useState(false); + const [setupStarted, setSetupStarted] = useState(false); + const [connectionQuery, setConnectionQuery] = useState(''); + const [confirmRevoke, setConfirmRevoke] = useState(null); + const [selectedGrantId, setSelectedGrantId] = useState(''); + const [loadFailures, setLoadFailures] = useState([]); + const loadRequest = useRef(0); + const stepHeading = useRef(null); + const connectionsAction = useRef(null); + const revokeAction = useRef(null); + const revokeTriggers = useRef>({}); + const previousRevoke = useRef(null); + const focusStep = useRef(false); + const setupSteps = ['Choose app', 'Review access', 'Connect', 'Verify'] as const; + + function moveStep(next: number) { + focusStep.current = true; + setSetupStep(next); + } + + useLayoutEffect(() => { + if (focusStep.current) { + (setupOpen ? stepHeading.current : connectionsAction.current)?.focus(); + focusStep.current = false; + } + }, [setupStep, setupOpen]); + + useLayoutEffect(() => { + if (confirmRevoke) revokeAction.current?.focus(); + else if (previousRevoke.current) + (revokeTriggers.current[previousRevoke.current] ?? connectionsAction.current)?.focus(); + previousRevoke.current = confirmRevoke; + }, [confirmRevoke]); const selectedClientOption = clientById(selectedClient); const selectedScopes = useMemo(() => { @@ -419,53 +498,46 @@ export default function McpAccessPage() { if (accessPreset === 'operate') return ALL_SCOPES; return customScopes; }, [accessPreset, customScopes]); - const testPrompts = useMemo(() => { - const prompts = [...READ_TEST_PROMPTS]; - if (selectedScopes.some((scope) => ['write:tasks', 'write:messages', 'write:wiki', 'write:modules'].includes(scope))) { - prompts.push(...COLLABORATE_TEST_PROMPTS); - } - if (selectedScopes.includes('write:workspace') || selectedScopes.includes('write:calendar')) { - prompts.push(...OPERATE_TEST_PROMPTS); - } - return prompts; - }, [selectedScopes]); const load = useCallback(async () => { + const request = ++loadRequest.current; setLoading(true); - setError(null); - try { - const res = await api.get('/api/mcp-access/tokens'); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const body = await res.json(); - setTokens(body.tokens ?? []); - setEndpoint(body.mcp_endpoint_url ?? ''); - const [readinessRes, grantsRes, historyRes] = await Promise.all([ - api.get('/api/oauth/readiness'), - api.get('/api/oauth/grants'), - api.get('/api/mcp-access/history'), - ]); - setRemote(readinessRes.ok ? await readinessRes.json() : null); - if (grantsRes.ok) { - const grantsBody = await grantsRes.json(); - setGrants(grantsBody.grants ?? []); - } - if (historyRes.ok) { - const historyBody = await historyRes.json(); - setHistory({ - revoked_tokens: historyBody.revoked_tokens ?? [], - revoked_grants: historyBody.revoked_grants ?? [], - }); - } - } catch (err) { - setError((err as Error).message); - } finally { - setLoading(false); + const resources = [ + ['Personal tokens', '/api/mcp-access/tokens'], + ['Connector settings', '/api/oauth/readiness'], + ['App authorizations', '/api/oauth/grants'], + ['Connection history', '/api/mcp-access/history'], + ] as const; + const results = await Promise.allSettled( + resources.map(async ([, path]) => { + const response = await api.get(path); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + }), + ); + if (request !== loadRequest.current) return; + const [tokenResult, readinessResult, grantResult, historyResult] = results; + if (tokenResult.status === 'fulfilled') { + setTokens(tokenResult.value.tokens ?? []); + setEndpoint(tokenResult.value.mcp_endpoint_url ?? ''); } + setRemote(readinessResult.status === 'fulfilled' ? readinessResult.value : null); + if (grantResult.status === 'fulfilled') setGrants(grantResult.value.grants ?? []); + if (historyResult.status === 'fulfilled') + setHistory({ + revoked_tokens: historyResult.value.revoked_tokens ?? [], + revoked_grants: historyResult.value.revoked_grants ?? [], + }); + setLoadFailures(results.flatMap((result, index) => (result.status === 'rejected' ? [resources[index][0]] : []))); + setLoading(false); }, []); - useEffect(() => { void load(); }, [load]); + useEffect(() => { + void load(); + }, [load]); function chooseClient(id: ClientId) { + if (busy || newToken || id === selectedClient) return; const client = clientById(id); setSelectedClient(id); setAccessPreset(client.defaultPreset); @@ -475,20 +547,24 @@ export default function McpAccessPage() { } function toggleCustomScope(scope: string) { - setCustomScopes((current) => ( - current.includes(scope) - ? current.filter((item) => item !== scope) - : [...current, scope] - )); + setCustomScopes((current) => + current.includes(scope) ? current.filter((item) => item !== scope) : [...current, scope], + ); } async function copy(label: string, value: string) { - await navigator.clipboard.writeText(value); - setCopied(label); - setTimeout(() => setCopied(null), 1500); + setError(null); + try { + await navigator.clipboard.writeText(value); + setCopied(label); + setTimeout(() => setCopied(null), 1500); + } catch { + setError('Could not copy to the clipboard. Select and copy the text manually.'); + } } async function createToken() { + if (busy || newToken || !selectedScopes.length) return; setBusy(true); setError(null); try { @@ -502,8 +578,11 @@ export default function McpAccessPage() { } const body = await res.json(); setNewToken(body.token); + setIssuedTokenId(body.token_id ?? null); + setTokenSaved(false); setEndpoint(body.mcp_endpoint_url ?? endpoint); await load(); + setConfirmRevoke(null); } catch (err) { setError((err as Error).message); } finally { @@ -518,8 +597,9 @@ export default function McpAccessPage() { const res = await api.delete(`/api/mcp-access/tokens/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); await load(); - } catch (err) { - setError((err as Error).message); + setConfirmRevoke(null); + } catch { + setError('Could not confirm revocation. Refresh the connection list to check its status, then try again.'); } finally { setBusy(false); } @@ -532,8 +612,9 @@ export default function McpAccessPage() { const res = await api.delete(`/api/oauth/grants/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); await load(); - } catch (err) { - setError((err as Error).message); + setConfirmRevoke(null); + } catch { + setError('Could not confirm revocation. Refresh the connection list to check its status, then try again.'); } finally { setBusy(false); } @@ -557,21 +638,20 @@ export default function McpAccessPage() { if (selectedClient === 'claude-code') { return { title: 'Claude Code CLI', - detail: 'Run this in a terminal. Then use /mcp in Claude Code to verify the connection. Do not paste the token into a Claude chat.', + detail: + 'Run this in a terminal. Then use /mcp in Claude Code to verify the connection. Do not paste the token into a Claude chat.', value: `claude mcp add --transport http --scope user deft "${endpointForConfig}" --header "Authorization: Bearer ${tokenForConfig}"`, }; } return { title: 'Raw endpoint and bearer header', detail: 'Use this when your client asks for the MCP URL and authorization header separately.', - value: [ - `MCP URL: ${endpointForConfig}`, - `Authorization: Bearer ${tokenForConfig}`, - ].join('\n'), + value: [`MCP URL: ${endpointForConfig}`, `Authorization: Bearer ${tokenForConfig}`].join('\n'), }; }, [endpointForConfig, selectedClient, tokenForConfig]); const tokenSetupClient = selectedClientOption.setupKind === 'token' || selectedClientOption.setupKind === 'advanced'; + const issuedConnection = tokens.find((token) => token.id === issuedTokenId); const historyCount = (history?.revoked_tokens.length ?? 0) + (history?.revoked_grants.length ?? 0); const remoteRows: Array<[string, string | undefined]> = [ ['Connector URL', remote?.mcp_endpoint_url], @@ -582,7 +662,6 @@ export default function McpAccessPage() { ['Registration endpoint', remote?.registration_endpoint], ]; const isClaudeConnector = selectedClient === 'claude-desktop'; - const isChatGptConnector = selectedClient === 'remote-web'; const remoteSteps = isClaudeConnector ? [ 'In Claude, open Settings or Customize -> Connectors. Do not paste MCP JSON or tokens into a chat.', @@ -594,7 +673,7 @@ export default function McpAccessPage() { 'In ChatGPT web, enable developer mode for an eligible account, then open Settings -> Apps -> Create.', 'Use the Connector URL below and choose OAuth authentication. Deft publishes the discovery metadata automatically.', 'Click Scan Tools and complete the Deft authorization screen. Deft starts scope-less connections read-only; add write permissions only when your ChatGPT plan supports full MCP.', - 'Create the draft app, enable it in a new chat, and confirm a read. On Business or Enterprise/Edu, test a write action and approve ChatGPT\'s confirmation prompt.', + "Create the draft app, enable it in a new chat, and confirm a read. On Business or Enterprise/Edu, test a write action and approve ChatGPT's confirmation prompt.", ]; const claudeConnectorFields = [ { @@ -607,7 +686,7 @@ export default function McpAccessPage() { label: 'Remote MCP server URL', value: remote?.mcp_endpoint_url ?? (loading ? 'Loading connector URL...' : 'Connector URL unavailable'), copyValue: remote?.mcp_endpoint_url, - help: 'Paste this into Claude\'s required URL field.', + help: "Paste this into Claude's required URL field.", }, { label: 'OAuth Client ID (optional)', @@ -621,586 +700,857 @@ export default function McpAccessPage() { }, ]; - return ( -
- + const connections = [ + ...tokens.map((token) => ({ + ...token, + key: `token:${token.id}`, + kind: 'token' as const, + label: 'Personal token', + displayName: token.name, + })), + ...grants.map((grant) => ({ + ...grant, + key: `grant:${grant.id}`, + kind: 'grant' as const, + label: 'App authorization', + displayName: grant.app_name, + })), + ]; + const filteredConnections = connections.filter((connection) => + `${connection.displayName} ${connection.label}`.toLowerCase().includes(connectionQuery.trim().toLowerCase()), + ); + const inventoryFailures = loadFailures.filter((name) => name === 'Personal tokens' || name === 'App authorizations'); + const selectedPreset = PRESETS.find((preset) => preset.id === accessPreset)!; + const verificationConnection = tokenSetupClient + ? issuedConnection + : grants.find((grant) => grant.id === selectedGrantId); + const verificationUnavailable = loadFailures.includes(tokenSetupClient ? 'Personal tokens' : 'App authorizations'); + const promptScopes = verificationConnection?.scopes ?? selectedScopes; + const prompt = promptScopes.includes('read:messages') + ? READ_TEST_PROMPTS[0] + : promptScopes.includes('read:tasks') + ? READ_TEST_PROMPTS[1] + : promptScopes.includes('read:wiki') + ? READ_TEST_PROMPTS[2] + : READ_TEST_PROMPTS[3]; + const border = { borderColor: 'var(--border-default)' }; + const muted = { color: 'var(--text-secondary)' }; + const buttonClass = + 'inline-flex min-h-10 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-40'; + const primaryStyle = { + background: 'var(--connection-action)', + borderColor: 'var(--connection-action)', + color: 'white', + }; + + function openSetup() { + focusStep.current = true; + setSetupStarted(true); + setSetupOpen(true); + setError(null); + } -
- {error && ( -
- {error} -
- )} - {copied && ( -
- Copied {copied} + function closeSetup() { + focusStep.current = true; + setSetupOpen(false); + } + + function finishSetup() { + closeSetup(); + setSetupStarted(false); + setSetupStep(0); + setNewToken(null); + setIssuedTokenId(null); + setTokenSaved(false); + setSelectedGrantId(''); + } + + return ( +
+
+
+

Personal AI connections

+

+ Connect your AI apps to Deft. You control their access. +

+
+ {error && !setupOpen && !confirmRevoke && ( +
+

{error}

+
)} -
-
- -

Manage active connections

-
- {loading ? ( -
- ) : ( -
-
-
Personal tokens
- {tokens.length === 0 ? ( -

No personal MCP tokens yet.

- ) : ( -
- {tokens.map((token) => ( -
-
-
-
{token.name}
-
- {token.token_prefix}... / last used {formatDate(token.last_used_at)} -
-
- -
-
- {isStale(token.last_used_at) && ( - - {token.last_used_at ? 'stale' : 'unused'} - - )} - {token.scopes.map((scope) => ( - {scope} - ))} + {!setupOpen ? ( + <> +
+
+
+

+ Your connections +

+

+ These apps act as you and can only access work you can see. +

+
+ +
+ {newToken && !tokenSaved && ( +

+ Your new token is still in setup. Resume to save it before refreshing or leaving this page. +

+ )} +
+ + +
+ {inventoryFailures.length > 0 && ( +

+ Could not refresh {inventoryFailures.join(' and ').toLowerCase()}. The list may be incomplete or out + of date. Retry with Refresh. +

+ )} + {loading && connections.length === 0 ? ( +

+ Loading your connections… +

+ ) : filteredConnections.length === 0 ? ( +
+

+ {connectionQuery.trim() + ? 'No matching connections' + : inventoryFailures.length + ? 'Connections unavailable' + : 'Connect your first AI app'} +

+

+ {connectionQuery.trim() + ? 'Try a different name or clear your search.' + : inventoryFailures.length + ? 'Refresh to try loading your connections again.' + : 'Bring your workspace into Codex, Claude or another supported client.'} +

+ {connectionQuery.trim() && ( + + )} +
+ ) : ( +
+ {filteredConnections.map((connection) => ( +
+ + + {connection.kind === 'token' ? : } + + + {connection.displayName} + + {connection.label} ·{' '} + {connection.last_used_at + ? `Last used ${formatDate(connection.last_used_at)}` + : 'Not used yet'} + + + + +
+
+

Access

+

+ {accessSummary(connection.scopes)} +

+
-
-
Recent token activity
- +
+

Recent activity

+
-
- ))} -
- )} -
- -
-
OAuth app grants
- {grants.length === 0 ? ( -

No ChatGPT or Claude-style OAuth connections yet.

- ) : ( -
- {grants.map((grant) => ( -
-
-
-
{grant.app_name}
-
- {grant.connector_profile} / last used {formatDate(grant.last_used_at)} +

+ Added {formatDate(connection.created_at)} + {connection.kind === 'token' + ? ` · Token ${connection.token_prefix}…` + : ` · ${connection.connector_profile}`} +

+ {confirmRevoke === connection.key ? ( +
+ {error && ( +

+ {error} +

+ )} +

+ Revoke access for {connection.displayName}? It will stop accessing Deft. + To use it again, you will need to reconnect. +

+
+ +
- -
-
- {isStale(grant.last_used_at) && ( - - {grant.last_used_at ? 'stale' : 'unused'} - - )} - {grant.scopes.map((scope) => ( - {scope} - ))} -
- + )}
- ))} -
- )} -
-
- )} -
-
- Add connection -

Choose your AI client, review its access, then connect it as yourself. Shared workers belong in Agent employees.

-
-
-
-
-
- -
-
-

What do you want to connect?

-

- Pick the app first. Deft will show the right connection path instead of making you sort through every MCP detail. -

-
-
+
+ ))} +
+ )} +
+ +
+ + {historyOpen && ( +
+ {loadFailures.includes('Connection history') ? ( +

+ History could not be refreshed. Use Refresh to retry. +

+ ) : loading && !history ? ( +

+ Loading history… +

+ ) : historyCount === 0 ? ( +

+ No revoked connections. +

+ ) : ( + [ + ...(history?.revoked_tokens.map((token) => ({ + ...token, + key: `token:${token.id}`, + displayName: token.name, + label: `Personal token ${token.token_prefix}…`, + })) ?? []), + ...(history?.revoked_grants.map((grant) => ({ + ...grant, + key: `grant:${grant.id}`, + displayName: grant.app_name, + label: grant.connector_profile, + })) ?? []), + ].map((connection) => ( +
+ + {connection.displayName} + + Revoked {formatDate(connection.revoked_at)} + + +

+ {connection.label} · Last used {formatDate(connection.last_used_at)} +

+ + +
+ )) + )} +
+ )} +
+ + ) : ( +
+ + +

+ {setupStep === 0 + ? 'Which app would you like to connect?' + : setupStep === 1 + ? `Review access for ${selectedClientOption.name}` + : setupStep === 2 + ? `Connect ${selectedClientOption.name}` + : 'Check your connection'} +

+

+ {setupStep === 0 + ? 'Choose an app to see its setup instructions.' + : setupStep === 1 + ? 'Choose the access this app needs. Every action is recorded under your name.' + : setupStep === 2 + ? 'Complete the setup in your app, then come back to check its activity.' + : 'Run a request from your app and check whether Deft receives it.'} +

+ {error && ( +

+ {error} +

+ )} + {newToken && setupStep < 2 && ( +

+ A token has already been issued with these permissions. Return to Connect to save it. Start another + connection after finishing this setup to choose different access. +

+ )} -
- {CLIENT_OPTIONS.map((client) => { - const active = client.id === selectedClient; - return ( + {setupStep === 0 && ( +
+ {CLIENT_OPTIONS.map((client) => ( - ); - })} -
-
-
+ ))} +
+ )} -
-
- {tokenSetupClient && ( -
-
-
- -
-
-

Choose what {selectedClientOption.name} can do

-

- Personal MCP tokens act as you. The app can only see what you can see, and every write is recorded under your name. + {setupStep === 1 && + (tokenSetupClient ? ( +

+ +
+

{selectedPreset.title}

+

+ {selectedPreset.detail} +

+

+ This does not give the app access to other people’s private work or bypass workspace permissions.

- -
- {PRESETS.map((preset) => { - const active = preset.id === accessPreset; - return ( - - ); - })} -
- - {accessPreset === 'custom' && ( -
- {AVAILABLE_SCOPES.map((scope) => ( - - ))} -
- )} - -
- {selectedScopes.map((scope) => )} -
+ {accessPreset === 'custom' ? ( +
+ {AVAILABLE_SCOPES.map((scope) => ( + + ))} + {selectedScopes.length === 0 && ( +

+ Select at least one permission to continue. +

+ )} +
+ ) : ( + + )} +
+ ) : ( +
+

+ {selectedClientOption.setupKind === 'agent' + ? 'Shared employees have their own workspace access, policies and activity. Configure those in Agent employees.' + : 'Your app will open Deft’s authorization screen. Review the requested permissions there before allowing access.'} +

+

+ {selectedClientOption.setupKind === 'agent' + ? 'Your personal token will not be used to represent a shared employee.' + : 'You can inspect and revoke the resulting connection from Your connections.'} +

-
- )} + ))} - {tokenSetupClient && ( -
-
-
- -
-
-

Create the connection

-

- Generate a token, copy the config for {selectedClientOption.name}, then test it from the AI app. + {setupStep === 2 && tokenSetupClient && ( +

+
{ + event.preventDefault(); + void createToken(); + }} + className="space-y-3" + > + +

+ {accessSummary(selectedScopes)} Go back to review the permissions. +

+ {!newToken && ( + + )} +
+ {newToken && ( +
+
+

Save your token

+ +
+

+ It is shown only during this setup. Store it securely and never paste it into an AI chat.

-
+
+                      {newToken}
+                    
+
- - {newToken && ( -
-
-
-
New token
-

Copy it now. Deft will not show it again.

-
- -
-
{newToken}
-
- )} - -
-
-
-
{selectedSnippet.title}
-

{selectedSnippet.detail}

-
- -
-
{selectedSnippet.value}
-
+ I have saved this token securely. + +
+ )} +
+
+

{selectedSnippet.title}

+
+

+ {selectedSnippet.detail} +

+ {!newToken && ( +

+ Preview only. Generate a token to fill in the authorization value. +

+ )} +
+                    {selectedSnippet.value}
+                  
-
+ {!tokenSaved && ( +

+ {newToken + ? 'Confirm that you saved the token before continuing.' + : 'Generate a token and save it to continue.'} +

+ )} +
)} - {selectedClientOption.setupKind === 'oauth' && ( -
-
-
- + {setupStep === 2 && selectedClientOption.setupKind === 'oauth' && ( +
+ {!remote ? ( +
+

+ {loading + ? 'Loading connector settings…' + : 'Connector settings are unavailable. Retry before connecting your app.'} +

+
-
-
-
-

- {isClaudeConnector ? 'Claude connector setup' : 'ChatGPT custom app setup'} -

-

- {isClaudeConnector - ? 'Claude connectors are added from Claude settings at the account level. The URL/token cannot be pasted into an active chat and expected to work.' - : 'ChatGPT connects from OpenAI cloud, so Deft must be publicly reachable over HTTPS. The app uses Deft OAuth; do not paste a personal token into ChatGPT.'} -

-
- - {remote ? (remote.https_ready ? 'HTTPS ready' : 'Needs public HTTPS') : (loading ? 'Checking readiness...' : 'Status unavailable')} - -
- {!loading && !remote && ( -
-

Could not load connector settings. Retry before connecting your AI app.

- -
- )} -
-
- -
-
- {isClaudeConnector ? 'Use Claude settings, not chat' : 'Use the app connector settings'} + ) : ( + !remote.https_ready && ( +

+ Your Deft server needs a public HTTPS address before a hosted app can connect. Ask your workspace + administrator to configure it. +

+ ) + )} +
    + {remoteSteps.map((step) => ( +
  1. {step}
  2. + ))} +
+ {isClaudeConnector ? ( +
+ {claudeConnectorFields.map((field) => ( +
+
+
+

{field.label}

+

+ {field.value} +

-
    - {remoteSteps.map((step) =>
  1. {step}
  2. )} -
-
-
-
-
- Security note: never paste a live bearer token into Claude, ChatGPT, or any AI chat. If you already did, revoke that personal token below and generate a fresh one. Remote Claude connectors should authenticate through Deft OAuth. -
- {isChatGptConnector && ( -
- ChatGPT availability: full read/write custom MCP apps currently require Business or Enterprise/Edu on ChatGPT web. Pro developer mode is limited to read/fetch access. Workspace admin or developer-mode controls may also apply. -
- )} - {isClaudeConnector && ( -
-
Fill Claude's four fields like this
-
- {claudeConnectorFields.map((field) => ( -
-
-
-
{field.label}
-
{field.value}
-
- {field.copyValue && ( - - )} -
-

{field.help}

-
- ))} + {field.copyValue && ( + + )}
+

+ {field.help} +

- )} -
- {remoteRows.slice(0, 3).map(([label, value]) => ( -
-
{label}
-
- {value ?? 'Not loaded'} - {value && ( - - )} -
-
- ))} + ))} +
+ ) : ( +
+
+

Connector URL

+ {remote?.mcp_endpoint_url && ( + + )}
- +

+ {remote?.mcp_endpoint_url ?? 'Unavailable'} +

-
-
+ )} +

+ Sign in through Deft’s authorization screen. Do not paste a personal token into a hosted AI chat. +

+
)} - {selectedClientOption.setupKind === 'agent' && ( -
-
-
- -
-
-

Use Agent Employees for shared workers

-

- Personal AI app connections act as you. If Hermes, OpenClaw, Codex, or another runtime should be available to the whole company as an employee, onboard it as an Agent Employee instead. -

- - Open Agent Employees - -
-
-
+ {setupStep === 2 && selectedClientOption.setupKind === 'agent' && ( +

+ Continue in Agent employees to create or manage the shared worker, choose its access and configure its + runtime. +

)} -
- -
- + )} +
+ + {setupStep === 2 && selectedClientOption.setupKind === 'agent' ? ( + + Open Agent Employees + + ) : setupStep < 3 ? ( + + ) : ( +
+ + +
+ )} +
+ + )} -
- -
+
- {advancedOpen && ( -
- {remoteRows.map(([label, value]) => ( -
-
{label}
-
- {value ?? 'Not loaded'} - {value && ( - - )} +
+ {loadFailures.includes('Connector settings') && ( +
+

Connector metadata is unavailable.

+ +
+ )} + {[['MCP endpoint', endpoint || undefined], ...remoteRows].map(([label, value]) => ( +
+
+

{label}

+ + {value ?? 'Unavailable'} +
+ {value && }
))} -
-
Supported remote scopes
-
- {(remote?.scopes ?? AVAILABLE_SCOPES).map((scope) => )} -
-
-
- )} -
- -
- - - {historyOpen && ( -
- {!history || historyCount === 0 ? ( -

No revoked connections yet.

- ) : ( - <> - {history.revoked_tokens.length > 0 && ( -
-
- Personal tokens -
-
- {history.revoked_tokens.map((token) => ( -
-
-
-
{token.name}
-
- {token.token_prefix}... / revoked {formatDate(token.revoked_at)} / last used {formatDate(token.last_used_at)} -
-
- - revoked - -
-
- {token.scopes.map((scope) => ( - {scope} - ))} -
- -
- ))} -
+ +
+ Memory context +
+ {CONTEXT_PACKET_CARDS.map((card) => ( +
+
{card.title}
+
+ {card.detail} +
- )} - - {history.revoked_grants.length > 0 && ( -
-
- AI app connections -
-
- {history.revoked_grants.map((grant) => ( -
-
-
-
{grant.app_name}
-
- {grant.connector_profile} / revoked {formatDate(grant.revoked_at)} / last used {formatDate(grant.last_used_at)} -
-
- - revoked - -
-
- {grant.scopes.map((scope) => ( - {scope} - ))} -
- -
- ))} -
-
- )} - - )} + ))} +
+
)}
diff --git a/apps/web/src/components/settings-steps.tsx b/apps/web/src/components/settings-steps.tsx new file mode 100644 index 00000000..6af0533e --- /dev/null +++ b/apps/web/src/components/settings-steps.tsx @@ -0,0 +1,14 @@ +export function SettingsSteps({ steps, current }: { steps: readonly string[]; current: number }) { + return ( + + ); +} diff --git a/docs/superpowers/plans/2026-09-09-settings-maturity-program.md b/docs/superpowers/plans/2026-09-09-settings-maturity-program.md index 209cc792..4f96278d 100644 --- a/docs/superpowers/plans/2026-09-09-settings-maturity-program.md +++ b/docs/superpowers/plans/2026-09-09-settings-maturity-program.md @@ -6,7 +6,8 @@ a claim that Settings is complete. Resume from the first unfinished milestone. ## Workspace and existing evidence -Work in `C:/tmp/deft-settings-structure`, branch `codex/settings-structure`. +Work in `C:/tmp/deft-settings-structure`; current implementation branch is +`codex/settings-connection-flow`, based on `codex/settings-structure` at 5f6f0d86. PR #323 contains the first structural pass and connection overflow repair. Preserve the original worktree and unrelated changes. Keep milestones separately reviewable; use explicitly based follow-up branches/PRs for subsequent features, @@ -139,6 +140,64 @@ production deployment, real credential grants or external messages are implied. - Completed: existing route inventory and shared interaction specification above. - Existing repair: PR #323, commit 6e0ec97b, 54 rendered connection states verified; web lint/typecheck/navigation tests passed. Updated production smoke pending. -- Next: inspect full connection-page handlers and extract a client/access/connect/ - verify state model, then implement the reference flow on a follow-up branch. +- Implemented: guided connection setup on `codex/settings-connection-flow`: + compact client selection; access review; client-specific connection instructions; + explicit verification. Shared employee setup exits to its existing destination. + Added reusable SettingsSteps progress with current-step semantics and step focus. + Existing token scope payloads and OAuth permissions are unchanged. New tokens + lock the client, name and scopes, prevent duplicate generation, and require an + explicit saved-token acknowledgement before advancing. Back/Next retains drafts + and the one-time token. Clipboard failures produce local feedback. +- Verification uses the issued token_id against the existing inventory response; + it does not infer connection success from completing instructions or another + token's activity. Contextual memory guidance is expandable. +- Fresh evidence: all seven client branches traversed at 390 and 1440 CSS px, + without visible section overflow; name and custom permissions retained across + steps; rejected issuance kept the Connect step; inert synthetic issuance tested + acknowledgement gating, duplicate prevention, locked client/access controls and + pending verification despite another fixture token having recent use. The fixture + was returned to read-only mode afterward. Typecheck, full web lint, navigation + tests and smoke-script syntax passed. Production smoke now traverses the steps, + checks command containment and name retention; CI execution remains pending. +- Screenshots: `tmp/preview-evidence/guided-access-desktop.png` and + `guided-connect-mobile.png`. These are fixture-based visual evidence only. +- Whole-page follow-through (PR #324, stacked on #323): replaced the management + dashboard with a searchable unified token/authorization list, expandable access + and activity, inline revoke confirmation, and restrained history/developer + disclosures. Setup is a separate view with compact client rows, a plain-language + access selector, focused credential instructions and connection-specific checks. + Cancelling preserves drafts; finishing clears the acknowledged credential. + Partial request failures retain the last inventory with an explicit warning; + history and metadata failures have distinct retry states. Verification prompts + follow the selected connection's actual permissions. Copy feedback is local. +- Fresh whole-page evidence: seven client paths at 390 and 1440 CSS px without + section overflow; management and expanded revoke confirmation also checked at + 320 px. Inspected dark desktop/mobile and light desktop/reflow layouts. Tested + search/clear, empty state, partial load failures/recovery, token and grant revoke, + failed revoke/retry, custom-scope validation, draft retention, inert issuance, + acknowledgement/duplicate guards, pending and fixture-observed activity, + grant-specific prompt permissions, and focus on steps/confirm/cancel/finish. + Web typecheck, full web lint, three navigation tests, smoke syntax and diff check + passed. Production smoke now includes issuance/acknowledgement/revoke against + its disposable seeded backend; that updated smoke has not been run locally. +- Current screenshots: `tmp/preview-evidence/connections-whole-desktop.png`, + `connections-whole-mobile.png`, `connections-whole-light.png`, and + `connections-access-desktop.png` / `connections-access-mobile.png`. + All are synthetic fixture evidence, not proof of OAuth or persistence. Browser + zoom and screen-reader acceptance remain unverified; narrow reflow is not a zoom + test. The preview fixture returns to read-only mode after the interaction pass. +- Next: obtain database-backed integration and independent usability evidence for + this page, strengthen zoom/screen-reader acceptance, then apply the patterns to + Profile/Calendar/AI. Module ownership and canonical employee work require their + explicit contract/control audits before implementation. - Open: dependency audit, real integration evidence, independent usability review. +- Visual polish follow-up: page-local CSS strengthens field/row boundaries and + surface contrast, aligns setup with the page heading, tightens section spacing, + distinguishes heading levels, deepens primary button colour, and adds consistent + hover/focus treatment with reduced-motion support. No API or permission changes. + Inspected desktop dark/light, mobile permissions/connect/expanded details, and + keyboard focus; custom permissions had no section overflow at 390 CSS px. + Fresh web typecheck, focused page lint and diff checks passed. Evidence lives in + `tmp/preview-evidence/polish-*.png`. This is fixture-based visual verification. +- The Settings implementation schedule was deleted at the user's request. Continue + only on direct user instruction; do not recreate it from this checkpoint. diff --git a/scripts/product-browser-smoke.mjs b/scripts/product-browser-smoke.mjs index 2a4ae348..6cdcccf4 100644 --- a/scripts/product-browser-smoke.mjs +++ b/scripts/product-browser-smoke.mjs @@ -107,7 +107,7 @@ async function main() { ['/settings', 'Settings'], ['/settings/profile', 'Profile'], ['/settings/apps', 'Apps'], - ['/settings/mcp-access', 'Manage active connections'], + ['/settings/mcp-access', 'Your connections'], ]) { await settle(page, path); await page.getByRole('heading', { name: heading }).first().waitFor({ timeout: 10_000 }); @@ -117,23 +117,62 @@ async function main() { } record('Navigate core settings surfaces'); - await page.getByText('Add connection', { exact: true }).click(); - await page.getByRole('heading', { name: 'What do you want to connect?', exact: true }).waitFor(); - await page.getByRole('button', { name: /^Claude Code Token setup/ }).click(); - await page.getByRole('button', { name: /^Choose individually/ }).click(); + await page.getByRole('searchbox', { name: 'Search connections' }).fill('no-such-smoke-connection'); + await page.getByRole('heading', { name: 'No matching connections' }).waitFor(); + await page.getByRole('button', { name: 'Clear search', exact: true }).click(); + await page.getByRole('button', { name: 'Add connection', exact: true }).click(); + await page.getByRole('heading', { name: 'Which app would you like to connect?', exact: true }).waitFor(); + await page.getByRole('button', { name: /^Claude Code Use a personal token/ }).click(); + await page.getByRole('button', { name: 'Review access', exact: true }).click(); + await page.getByRole('combobox', { name: 'Access level', exact: true }).selectOption('custom'); for (const width of [1440, 1024, 768, 390]) { await page.setViewportSize({ width, height: 900 }); - const setupOverflow = await page.locator('main aside').evaluate((aside) => { - const grid = aside.parentElement; - return Math.max( - grid.scrollWidth - grid.clientWidth, - ...Array.from(grid.querySelectorAll('section')).map((section) => section.scrollWidth - section.clientWidth), - ); - }); + const setupOverflow = await page.locator('section[aria-labelledby="setup-heading"]').evaluate((section) => section.scrollWidth - section.clientWidth); if (setupOverflow > 2) throw new Error(`Claude Code setup overflows its cards by ${setupOverflow}px at ${width}px`); + await page.getByRole('button', { name: 'Continue to connect', exact: true }).click(); + await page.getByRole('textbox', { name: 'Connection name', exact: true }).fill('Smoke test draft'); + const commandOverflow = await page.getByRole('textbox', { name: 'Connection name', exact: true }).evaluate((input) => { + const section = input.closest('section'); + return section.scrollWidth - section.clientWidth; + }); + if (commandOverflow > 2) throw new Error(`Claude Code command expands its card at ${width}px`); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByRole('button', { name: 'Continue to connect', exact: true }).click(); + if (await page.getByRole('textbox', { name: 'Connection name', exact: true }).inputValue() !== 'Smoke test draft') { + throw new Error('Connection name was lost between setup steps'); + } + await page.getByRole('button', { name: 'Back', exact: true }).click(); } record('Claude Code custom permissions stay inside setup cards at desktop, tablet and mobile widths'); + for (const checkbox of await page.getByRole('checkbox').all()) await checkbox.uncheck(); + if (await page.getByRole('button', { name: 'Continue to connect', exact: true }).isEnabled()) { + throw new Error('Setup permits an empty permission selection'); + } + await page.getByRole('checkbox', { name: 'Read tasks, comments and progress', exact: true }).check(); + await page.getByRole('button', { name: 'Continue to connect', exact: true }).click(); + const personalConnectionName = `Browser smoke ${Date.now()}`; + await page.getByRole('textbox', { name: 'Connection name', exact: true }).fill(personalConnectionName); + await page.getByRole('button', { name: 'Generate token', exact: true }).click(); + await page.getByRole('checkbox', { name: 'I have saved this token securely.', exact: true }).waitFor({ timeout: 10_000 }); + if (await page.getByRole('button', { name: 'Check connection', exact: true }).isEnabled()) { + throw new Error('Setup skipped the one-time token acknowledgement'); + } + await page.getByRole('checkbox', { name: 'I have saved this token securely.', exact: true }).check(); + await page.getByRole('button', { name: 'Check connection', exact: true }).click(); + await page.getByRole('heading', { name: 'Waiting for the first request', exact: true }).waitFor(); + await page.getByRole('button', { name: 'Your connections', exact: true }).click(); + await page.getByRole('searchbox', { name: 'Search connections' }).fill(personalConnectionName); + await page.getByText(personalConnectionName, { exact: true }).click(); + await page.getByRole('button', { name: 'Revoke access', exact: true }).click(); + await page.getByRole('button', { name: 'Keep connection', exact: true }).click(); + await page.getByRole('button', { name: 'Revoke access', exact: true }).click(); + await page.getByRole('button', { name: 'Confirm revoke', exact: true }).click(); + await page.getByRole('heading', { name: 'No matching connections', exact: true }).waitFor(); + await page.getByRole('button', { name: /^Connection history/ }).click(); + await page.getByText(personalConnectionName, { exact: false }).last().waitFor(); + record('Create, acknowledge, inspect and revoke a personal connection without exposing its token'); + await page.setViewportSize({ width: 1440, height: 900 }); await settle(page, '/settings/modules'); const availableTab = page.getByRole('button', { name: /Available/i }).first();