From 7ea13f7c8e413e7db9f6d309937a909ee426487c Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 8 Sep 2026 21:13:05 +0200 Subject: [PATCH] Move Browser profiles into Connections settings --- docs/full-local.md | 16 +++--- frontend/README.md | 4 ++ ....test.tsx => BrowserProfilesPane.test.tsx} | 14 ++--- ...ssionsPane.tsx => BrowserProfilesPane.tsx} | 14 ++--- .../src/components/SettingsPages.test.tsx | 27 ++++++++-- frontend/src/components/SettingsPages.tsx | 53 ++++++++++--------- frontend/src/pages/LoginWindowPage.tsx | 2 +- 7 files changed, 78 insertions(+), 52 deletions(-) rename frontend/src/components/{BrowserSessionsPane.test.tsx => BrowserProfilesPane.test.tsx} (95%) rename frontend/src/components/{BrowserSessionsPane.tsx => BrowserProfilesPane.tsx} (92%) diff --git a/docs/full-local.md b/docs/full-local.md index ec6ac645..32d50091 100644 --- a/docs/full-local.md +++ b/docs/full-local.md @@ -122,18 +122,18 @@ docker compose exec web druks doctor --sandbox This creates and deletes a real sandbox container. -## 4. Log in a browser session +## 4. Connect a browser profile -Create the browser session: +Installed apps declare the browser profiles they use. To save a login: -1. Open **Settings → Browser sessions**. -2. Create a stable session name. +1. Open **Settings → Connections → Browser**. +2. Find the profile for the site. 3. Choose **Log in**. Druks opens a headed browser in a disposable browser sandbox. 4. Authenticate on the site. 5. Choose **Save**. Druks closes the browser and stores its encrypted profile. - It marks the session as ready. + It marks the profile as ready. -When a site expires the login, the session becomes stale. Choose **Reconnect**. +When a site expires the login, the profile becomes stale. Choose **Reconnect**. Druks creates a new login window from the saved state. Authenticate again. Then save the replacement profile. @@ -141,9 +141,9 @@ save the replacement profile. a change to the saved state. A web-process restart also deletes open login windows. After Druks returns, open the window again. -To examine the complete path, save the session. Then run an app workflow that +To examine the complete path, save the profile. Then run an app workflow that borrows it. Make sure that its browser opens the authenticated site. A saved -login window always stores `profile_dir`. This rule also applies to a session +login window always stores `profile_dir`. This rule also applies to a profile that came from Playwright `storage_state`. ## 5. Exercise an app diff --git a/frontend/README.md b/frontend/README.md index 03d64f80..d4d1d762 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -51,6 +51,10 @@ a failed request keeps the operator on that page with its draft. Resource actions, such as connecting a provider or minting an API token, apply at once. Back to Druks restores the previous work URL and keeps the work page mounted. +Connections groups Services, Accounts, Browser, and Revoked. Its `tab` query +parameter selects the active tab. The Browser profiles page manages saved browser state +and login windows at `/settings/connections?tab=browser`. + App settings use `/apps//settings` in the work context. A gear beside the app name in the header and the central App settings index link to this same route. Options and Agents appear only when the app declares those controls. Both diff --git a/frontend/src/components/BrowserSessionsPane.test.tsx b/frontend/src/components/BrowserProfilesPane.test.tsx similarity index 95% rename from frontend/src/components/BrowserSessionsPane.test.tsx rename to frontend/src/components/BrowserProfilesPane.test.tsx index 4dd4b1cc..235641e8 100644 --- a/frontend/src/components/BrowserSessionsPane.test.tsx +++ b/frontend/src/components/BrowserProfilesPane.test.tsx @@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { BrowserSession } from '../api/types' import { api } from '../api/client' -import { BrowserSessionsPane } from './BrowserSessionsPane' +import { BrowserProfilesPane } from './BrowserProfilesPane' function browserSession(overrides: Partial = {}): BrowserSession { return { @@ -43,7 +43,7 @@ function renderPane() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) render( - + , ) } @@ -55,15 +55,15 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('BrowserSessionsPane', () => { +describe('BrowserProfilesPane', () => { it('shows a failed read without a false empty state and retries', async () => { vi.spyOn(api, 'browserSessions').mockRejectedValueOnce(new Error('unavailable')).mockResolvedValueOnce([]) renderPane() - expect(screen.getByRole('status').textContent).toBe('Loading browser sessions…') - expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Could not load browser sessions. Try again') - expect(screen.queryByText('No installed app declares a browser session.')).toBeNull() + expect(screen.getByRole('status').textContent).toBe('Loading browser profiles…') + expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'Could not load browser profiles. Try again') + expect(screen.queryByText('No installed app declares a browser profile.')).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Try again' })) - expect(await screen.findByText('No installed app declares a browser session.')).toBeTruthy() + expect(await screen.findByText('No installed app declares a browser profile.')).toBeTruthy() }) it('lists status, base-aware login actions, and refresh timestamps', async () => { vi.stubEnv('BASE_URL', '/druks/') diff --git a/frontend/src/components/BrowserSessionsPane.tsx b/frontend/src/components/BrowserProfilesPane.tsx similarity index 92% rename from frontend/src/components/BrowserSessionsPane.tsx rename to frontend/src/components/BrowserProfilesPane.tsx index d57d2b59..549ca707 100644 --- a/frontend/src/components/BrowserSessionsPane.tsx +++ b/frontend/src/components/BrowserProfilesPane.tsx @@ -27,7 +27,7 @@ const LOGIN_ACTION_LABELS: Record, st stale: 'Reconnect', } -export function BrowserSessionsPane() { +export function BrowserProfilesPane() { const queryClient = useQueryClient() const query = useQuery({ queryKey: ['browserSessions'], @@ -57,16 +57,16 @@ export function BrowserSessionsPane() { return (
-

Browser

+

Browser profiles

- Sign-ins your apps declare, kept as encrypted browser state. + Saved browser state for the sites your apps use.

- {query.isPending &&

Loading browser sessions…

} + {query.isPending &&

Loading browser profiles…

} {query.isError && (

- Could not load browser sessions.{' '} + Could not load browser profiles.{' '} @@ -80,10 +80,10 @@ export function BrowserSessionsPane() {

- Sessions {sessions.length} + Profiles {sessions.length}

{query.isSuccess && sessions.length === 0 && ( -

No installed app declares a browser session.

+

No installed app declares a browser profile.

)} {sessions.length > 0 && (
diff --git a/frontend/src/components/SettingsPages.test.tsx b/frontend/src/components/SettingsPages.test.tsx index f067cad4..02d5b6cd 100644 --- a/frontend/src/components/SettingsPages.test.tsx +++ b/frontend/src/components/SettingsPages.test.tsx @@ -449,14 +449,31 @@ afterEach(() => { }) describe('SettingsPages app fields', () => { - it('opens browser sessions from settings navigation', async () => { + it('opens browser profiles through Connections and settings search', async () => { stubFetch() renderSettings() - fireEvent.click(await screen.findByRole('link', { name: 'Browser sessions' })) + expect(screen.queryByRole('link', { name: 'Browser sessions' })).toBeNull() + fireEvent.click(await screen.findByRole('link', { name: 'Connections' })) + fireEvent.click(screen.getByRole('link', { name: 'Browser' })) - expect(await screen.findByRole('heading', { name: 'Browser' })).toBeTruthy() - expect(await screen.findByText('No installed app declares a browser session.')).toBeTruthy() + expect(await screen.findByRole('heading', { name: 'Browser profiles' })).toBeTruthy() + expect(await screen.findByText('No installed app declares a browser profile.')).toBeTruthy() + expect(window.location.search).toBe('?tab=browser') + + fireEvent.click(screen.getByRole('link', { name: 'Accounts' })) + expect(screen.queryByRole('heading', { name: 'Browser profiles' })).toBeNull() + fireEvent.change(screen.getByLabelText('Search settings'), { + target: { value: 'browser sessions' }, + }) + fireEvent.click( + within(screen.getByLabelText('Settings search results')).getByRole('link', { + name: /Browser profiles/, + }), + ) + expect(screen.getByRole('heading', { name: 'Browser profiles' })).toBeTruthy() + expect(screen.getByRole('link', { name: 'Browser' }).getAttribute('aria-current')).toBe('page') + expect(window.location.search).toBe('?tab=browser') }) it('spells an underscored app name out in the index and its options group', async () => { @@ -1339,7 +1356,7 @@ describe('settings resource read failures', () => { .mockResolvedValue([]) renderSettings(`/settings/${section}`) if (method === 'listConnections') { - fireEvent.click(await screen.findByRole('button', { name: 'Accounts' })) + fireEvent.click(await screen.findByRole('link', { name: 'Accounts' })) } const alert = await screen.findByRole('alert') expect(alert.textContent).toContain(`Could not load ${label}.`) diff --git a/frontend/src/components/SettingsPages.tsx b/frontend/src/components/SettingsPages.tsx index c8180f9f..fbbcb7f2 100644 --- a/frontend/src/components/SettingsPages.tsx +++ b/frontend/src/components/SettingsPages.tsx @@ -16,7 +16,7 @@ import { absTime } from '../lib/format' import { harnessColors } from '../lib/harnessColors' import { Page } from './Page' import { Sidebar } from './Sidebar' -import { BrowserSessionsPane } from './BrowserSessionsPane' +import { BrowserProfilesPane } from './BrowserProfilesPane' import { AgentAccessPane, AgentsPane, @@ -44,13 +44,19 @@ const SECTIONS = [ { id: 'connections', label: 'Connections', group: 'Tools & access' }, { id: 'mcp', label: 'MCP servers', group: 'Tools & access' }, { id: 'skills', label: 'Skills', group: 'Tools & access' }, - { id: 'browser-sessions', label: 'Browser sessions', group: 'Tools & access' }, { id: 'general', label: 'General', group: 'Installation' }, { id: 'personal', label: 'Preferences', group: 'Personal' }, { id: 'api-tokens', label: 'API tokens', group: 'Personal' }, { id: 'apps', label: 'App settings', group: 'Apps' }, ] +const CONNECTION_TABS = [ + { id: 'services', label: 'Services' }, + { id: 'accounts', label: 'Accounts' }, + { id: 'browser', label: 'Browser' }, + { id: 'revoked', label: 'Revoked' }, +] + function withField( current: Record | undefined, field: string, @@ -76,7 +82,10 @@ export function SettingsPages({ active?: boolean }) { const [location, navigate] = useLocation() - const fieldTarget = new URLSearchParams(useSearch()).get('field') + const searchParams = new URLSearchParams(useSearch()) + const fieldTarget = searchParams.get('field') + const connectionsTab = + CONNECTION_TABS.find((tab) => tab.id === searchParams.get('tab'))?.id ?? 'services' const content = useRef(null) const section = appName ? `apps/${appName}` : location.slice('/settings/'.length) || 'providers' const formPath = `${import.meta.env.BASE_URL.replace(/\/$/, '')}${appName ? `/apps/${appName}/settings` : '/settings'}` @@ -130,7 +139,6 @@ export function SettingsPages({ const [errors, setErrors] = useState>({}) const [saving, setSaving] = useState(false) const [search, setSearch] = useState('') - const [connectionsTab, setConnectionsTab] = useState('services') const [visited, setVisited] = useState([section]) if (!visited.includes(section)) setVisited([...visited, section]) const tick = useTicker() @@ -398,7 +406,11 @@ export function SettingsPages({ const searchResults = SECTIONS.map((entry) => ({ label: entry.label, owner: entry.group, kind: 'Section', terms: '', path: `/settings/${entry.id}`, - })).concat(Object.values(SETTINGS_FIELDS).map((field) => ({ + })).concat([{ + label: 'Browser profiles', owner: 'Connections', kind: 'Section', + terms: 'browser sessions sign-ins saved logins', + path: '/settings/connections?tab=browser', + }], Object.values(SETTINGS_FIELDS).map((field) => ({ label: field.label, owner: SECTIONS.find((entry) => entry.id === field.section)!.label, kind: 'Field', terms: field.terms, path: `/settings/${field.section}?field=${field.field}`, @@ -717,24 +729,15 @@ export function SettingsPages({ {page === 'connections' && ( <>