From 5f1551e593d8d9058b2389e011928cf0fccc43d5 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:25:44 +0530 Subject: [PATCH 1/3] feat(web): guide personal AI connection setup through focused steps --- .../app/(app)/settings/mcp-access/page.tsx | 115 ++++++++++++++---- apps/web/src/components/settings-steps.tsx | 14 +++ .../2026-09-09-settings-maturity-program.md | 30 ++++- scripts/product-browser-smoke.mjs | 14 +++ 4 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/settings-steps.tsx 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..e11cebde 100644 --- a/apps/web/src/app/(app)/settings/mcp-access/page.tsx +++ b/apps/web/src/app/(app)/settings/mcp-access/page.tsx @@ -1,6 +1,6 @@ '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, @@ -26,6 +26,7 @@ import { import { api } from '@/lib/api'; import { PageHeader } from '@/components/page-header'; import { useSetPageContext } from '@/components/app-header-context'; +import { SettingsSteps } from '@/components/settings-steps'; type McpToken = { id: string; @@ -411,6 +412,24 @@ 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 stepHeading = 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) { + stepHeading.current?.focus(); + focusStep.current = false; + } + }, [setupStep]); const selectedClientOption = clientById(selectedClient); const selectedScopes = useMemo(() => { @@ -466,6 +485,7 @@ export default function McpAccessPage() { useEffect(() => { void load(); }, [load]); function chooseClient(id: ClientId) { + if (busy || newToken || id === selectedClient) return; const client = clientById(id); setSelectedClient(id); setAccessPreset(client.defaultPreset); @@ -483,9 +503,13 @@ export default function McpAccessPage() { } async function copy(label: string, value: string) { - await navigator.clipboard.writeText(value); - setCopied(label); - setTimeout(() => setCopied(null), 1500); + 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() { @@ -502,6 +526,8 @@ 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(); } catch (err) { @@ -572,6 +598,7 @@ export default function McpAccessPage() { }, [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], @@ -729,8 +756,15 @@ export default function McpAccessPage() {
Add connection -

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

+
+ +

{setupSteps[setupStep]}

+

{setupStep === 0 ? 'Choose the app you want to use with Deft.' : `${selectedClientOption.name} · ${setupStep === 1 ? 'Understand what this connection can access.' : setupStep === 2 ? 'Follow the instructions for your app.' : 'Check that your app can reach Deft.'}`}

+ {newToken && setupStep < 2 &&

This token’s app and access are fixed. Return to Connect to copy it, or finish setup before starting another connection.

} + {error &&

{error}

} + {copied &&

Copied {copied}

}
+
+
-
-
+
+ -
- +
+ +
+ + {setupStep === 2 && selectedClientOption.setupKind === 'agent' ? ( + Open Agent Employees + ) : setupStep < 3 ? ( + + ) : ( + + )} +
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..5b4435d7 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,29 @@ 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. +- Next: finish connection management detail and local activity/error feedback, + strengthen keyboard/zoom and real issuance verification, then apply the shared + 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. diff --git a/scripts/product-browser-smoke.mjs b/scripts/product-browser-smoke.mjs index 2a4ae348..2b85574b 100644 --- a/scripts/product-browser-smoke.mjs +++ b/scripts/product-browser-smoke.mjs @@ -120,6 +120,7 @@ async function main() { 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: 'Review access', exact: true }).click(); await page.getByRole('button', { name: /^Choose individually/ }).click(); for (const width of [1440, 1024, 768, 390]) { await page.setViewportSize({ width, height: 900 }); @@ -131,6 +132,19 @@ async function main() { ); }); 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'); From e2c9911138b326a01f3b6877ef3d9aec85bef67d Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:01:08 +0530 Subject: [PATCH 2/3] Redesign the complete personal AI connections page --- .../app/(app)/settings/mcp-access/page.tsx | 1664 ++++++++++------- .../2026-09-09-settings-maturity-program.md | 33 +- scripts/product-browser-smoke.mjs | 49 +- 3 files changed, 1034 insertions(+), 712 deletions(-) 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 e11cebde..d2903c98 100644 --- a/apps/web/src/app/(app)/settings/mcp-access/page.tsx +++ b/apps/web/src/app/(app)/settings/mcp-access/page.tsx @@ -2,29 +2,8 @@ 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'; @@ -39,30 +18,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'; @@ -81,7 +49,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', @@ -99,7 +68,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', @@ -108,7 +78,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', @@ -117,7 +88,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', @@ -191,16 +163,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; @@ -290,7 +252,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; } @@ -331,20 +293,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]!; } @@ -358,7 +325,11 @@ function clientIcon(id: ClientId) { function RecentActionList({ actions }: { actions?: OAuthAuditAction[] }) { if (!actions?.length) { - return
No recent actions recorded.
; + return ( +
+ No recent actions recorded. +
+ ); } return (
@@ -366,18 +337,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}
); @@ -386,11 +363,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} - + ); } @@ -415,7 +456,18 @@ export default function McpAccessPage() { 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; @@ -426,10 +478,17 @@ export default function McpAccessPage() { useLayoutEffect(() => { if (focusStep.current) { - stepHeading.current?.focus(); + (setupOpen ? stepHeading.current : connectionsAction.current)?.focus(); focusStep.current = false; } - }, [setupStep]); + }, [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(() => { @@ -438,51 +497,43 @@ 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; @@ -495,14 +546,13 @@ 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) { + setError(null); try { await navigator.clipboard.writeText(value); setCopied(label); @@ -513,6 +563,7 @@ export default function McpAccessPage() { } async function createToken() { + if (busy || newToken || !selectedScopes.length) return; setBusy(true); setError(null); try { @@ -530,6 +581,7 @@ export default function McpAccessPage() { setTokenSaved(false); setEndpoint(body.mcp_endpoint_url ?? endpoint); await load(); + setConfirmRevoke(null); } catch (err) { setError((err as Error).message); } finally { @@ -544,8 +596,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); } @@ -558,8 +611,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); } @@ -583,17 +637,15 @@ 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]); @@ -609,7 +661,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.', @@ -621,7 +672,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 = [ { @@ -634,7 +685,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)', @@ -648,632 +699,853 @@ export default function McpAccessPage() { }, ]; + 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(--accent)', borderColor: 'var(--accent)', color: 'white' }; + + function openSetup() { + focusStep.current = true; + setSetupStarted(true); + setSetupOpen(true); + setError(null); + } + + function closeSetup() { + focusStep.current = true; + setSetupOpen(false); + } + + function finishSetup() { + closeSetup(); + setSetupStarted(false); + setSetupStep(0); + setNewToken(null); + setIssuedTokenId(null); + setTokenSaved(false); + setSelectedGrantId(''); + } + return (
- - -
- {error && ( -
- {error} -
- )} - {copied && ( -
- Copied {copied} +
+
+

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 -
- -

{setupSteps[setupStep]}

-

{setupStep === 0 ? 'Choose the app you want to use with Deft.' : `${selectedClientOption.name} · ${setupStep === 1 ? 'Understand what this connection can access.' : setupStep === 2 ? 'Follow the instructions for your app.' : 'Check that your app can reach Deft.'}`}

- {newToken && setupStep < 2 &&

This token’s app and access are fixed. Return to Connect to copy it, or finish setup before starting another connection.

} - {error &&

{error}

} - {copied &&

Copied {copied}

} -
-
+ ))} +
+ )} +
+ +
+ + {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) => ( - ); - })} -
-
-
-
+ ))} +
+ )} -
- )} - {selectedClientOption.setupKind === 'oauth' && setupStep === 2 && ( -
-
-
- + {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 && ( - - )} -
-
- ))} -
- -
-
-
- )} - - {selectedClientOption.setupKind === 'agent' && setupStep === 2 && ( -
-
-
- + ))}
-
-

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. + ) : ( +

+
+

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. +

+
)} -
- -
- - + )} -
-
- - {setupStep === 2 && selectedClientOption.setupKind === 'agent' ? ( - Open Agent Employees - ) : setupStep < 3 ? ( +
- ) : ( - - )} -
-
- -
- - - {advancedOpen && ( -
- {remoteRows.map(([label, value]) => ( -
-
{label}
-
- {value ?? 'Not loaded'} - {value && ( - - )} -
-
- ))} -
-
Supported remote scopes
-
- {(remote?.scopes ?? AVAILABLE_SCOPES).map((scope) => )} + {setupStep === 2 && selectedClientOption.setupKind === 'agent' ? ( + + Open Agent Employees + + ) : setupStep < 3 ? ( + + ) : ( +
+ +
-
+ )}
- )} -
+
+ )} -
+
- - {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} - ))} -
- -
- ))} -
-
- )} - - {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} - ))} -
- -
- ))} -
-
- )} - + {advancedOpen && ( +
+ {loadFailures.includes('Connector settings') && ( +
+

Connector metadata is unavailable.

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

{label}

+ + {value ?? 'Unavailable'} + +
+ {value && } +
+ ))} + +
+ Memory context +
+ {CONTEXT_PACKET_CARDS.map((card) => ( +
+
{card.title}
+
+ {card.detail} +
+
+ ))} +
+
)}
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 5b4435d7..b3dd8502 100644 --- a/docs/superpowers/plans/2026-09-09-settings-maturity-program.md +++ b/docs/superpowers/plans/2026-09-09-settings-maturity-program.md @@ -161,8 +161,33 @@ production deployment, real credential grants or external messages are implied. 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. -- Next: finish connection management detail and local activity/error feedback, - strengthen keyboard/zoom and real issuance verification, then apply the shared - patterns to Profile/Calendar/AI. Module ownership and canonical employee work - require their explicit contract/control audits before implementation. +- 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. diff --git a/scripts/product-browser-smoke.mjs b/scripts/product-browser-smoke.mjs index 2b85574b..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,20 +117,17 @@ 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('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('button', { name: /^Choose individually/ }).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'); @@ -148,6 +145,34 @@ async function main() { } 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(); From 6ca1bb7112d385494c888b4831a79780bdbd13f4 Mon Sep 17 00:00:00 2001 From: Maneek21 <208369276+Maneek21@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:15:27 +0530 Subject: [PATCH 3/3] Polish personal connection page contrast and layout --- .../(app)/settings/mcp-access/page.module.css | 98 +++++++++++++++++++ .../app/(app)/settings/mcp-access/page.tsx | 25 +++-- .../2026-09-09-settings-maturity-program.md | 10 ++ 3 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/app/(app)/settings/mcp-access/page.module.css 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 d2903c98..015b949e 100644 --- a/apps/web/src/app/(app)/settings/mcp-access/page.tsx +++ b/apps/web/src/app/(app)/settings/mcp-access/page.tsx @@ -6,6 +6,7 @@ import { Bot, Check, ChevronDown, Code2, Copy, Globe2, KeyRound, Plug, Wrench } import { api } from '@/lib/api'; import { useSetPageContext } from '@/components/app-header-context'; import { SettingsSteps } from '@/components/settings-steps'; +import styles from './page.module.css'; type McpToken = { id: string; @@ -736,7 +737,11 @@ export default function McpAccessPage() { 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(--accent)', borderColor: 'var(--accent)', color: 'white' }; + const primaryStyle = { + background: 'var(--connection-action)', + borderColor: 'var(--connection-action)', + color: 'white', + }; function openSetup() { focusStep.current = true; @@ -761,9 +766,9 @@ export default function McpAccessPage() { } return ( -
-
-
+
+
+

Personal AI connections

Connect your AI apps to Deft. You control their access. @@ -865,7 +870,7 @@ export default function McpAccessPage() { )}

) : ( -
+
{filteredConnections.map((connection) => (
@@ -877,7 +882,7 @@ export default function McpAccessPage() { {connection.displayName} - + {connection.label} ·{' '} {connection.last_used_at ? `Last used ${formatDate(connection.last_used_at)}` @@ -1029,7 +1034,7 @@ export default function McpAccessPage() {
) : ( -
+