From 8d85c289fe97492b24b7c4ad80519a1b5ee3ca75 Mon Sep 17 00:00:00 2001 From: Mathieu Audebert Date: Wed, 14 Oct 2026 16:05:51 +0200 Subject: [PATCH 1/2] feat(console): My account lists the browsers of the extension, and disconnects one (FRU-249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The end of FRU-249, on the routes of FRU-238 (#303): the browsers where the extension is connected to the account are rows of « Where you are signed in », after those of the console, as screen 13 draws them: « Extension · Chrome on macOS », « Connected 12 Oct · used 2 h ago ». The two lists are read together and drawn once, so nothing under them moves when the second answer comes. « Disconnect » asks twice, ends the session on the worker, and its next refresh is refused: a test on the worker with accounts holds it. Co-Authored-By: Claude Opus 5.5 --- apps/console/app/api.ts | 24 +++++ apps/console/app/messages.fr.ts | 8 ++ apps/console/app/routes/account.tsx | 66 +++++++++++- docs/decisions/console.md | 7 +- e2e/console-account.spec.ts | 152 ++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 3 deletions(-) diff --git a/apps/console/app/api.ts b/apps/console/app/api.ts index d4aa6e03..875a00fc 100644 --- a/apps/console/app/api.ts +++ b/apps/console/app/api.ts @@ -384,6 +384,30 @@ export async function signOut(): Promise { */ export type ConsoleSession = { id: string; createdAt: string; lastUsedAt: string; current: boolean; browser?: string }; +/** + * A browser where the extension is connected to the account (FRU-238, FRU-249): its short label, when + * it connected, and when it last refreshed its tokens. + */ +export type ExtensionBrowser = { id: string; browser: string; connectedAt: string; usedAt: string }; + +/** The browsers of the extension of the person. A refusal is no list. */ +export async function extensionBrowsers(): Promise> { + const listed = await call<{ browsers?: ExtensionBrowser[] }>('GET', '/console/me/browsers'); + if (!listed.ok) return listed; + + return Array.isArray(listed.data?.browsers) + ? { ok: true, data: listed.data.browsers } + : { ok: false, status: 200, error: 'unknown' }; +} + +/** Ends the session of the extension in one browser: its next refresh is refused. */ +export async function endExtensionBrowser(id: string): Promise<'ended' | 'unknown' | 'failed'> { + const ended = await call('DELETE', `/console/me/browsers/${encodeURIComponent(id)}`); + if (ended.ok) return 'ended'; + + return ended.status === 404 ? 'unknown' : 'failed'; +} + /** The providers that an account links (FRU-249). */ export const LINKABLE = ['github', 'google'] as const; export type Linkable = (typeof LINKABLE)[number]; diff --git a/apps/console/app/messages.fr.ts b/apps/console/app/messages.fr.ts index 36065e2d..bd2fd57b 100644 --- a/apps/console/app/messages.fr.ts +++ b/apps/console/app/messages.fr.ts @@ -665,4 +665,12 @@ export const FRENCH: Readonly> = { 'Vous ne vous connectez plus avec {provider}. Votre compte et ses notes restent.', 'Link {provider}': 'Lier {provider}', 'Console · {browser}': 'Console · {browser}', + // My account, the browsers of the extension (FRU-249). + 'Extension · {browser}': 'Extension · {browser}', + 'Connected {day} · used {last}': 'Connectée le {day} · utilisée {last}', + 'Disconnect the extension in {browser}': 'Déconnecter l’extension dans {browser}', + 'It stops showing notes at once. It connects again from its popup, with your approval here.': + 'Elle cesse d’afficher les notes tout de suite. Elle se reconnecte depuis son popup, avec votre accord ici.', + 'This extension is still connected: Fruitback did not end its session. Try again.': + 'Cette extension est toujours connectée : Fruitback n’a pas mis fin à sa session. Réessayez.', }; diff --git a/apps/console/app/routes/account.tsx b/apps/console/app/routes/account.tsx index 898f7f12..83a1c52d 100644 --- a/apps/console/app/routes/account.tsx +++ b/apps/console/app/routes/account.tsx @@ -2,12 +2,15 @@ import { type FormEvent, type ReactNode, useCallback, useEffect, useRef, useStat import { useNavigate, useSearchParams } from 'react-router'; import { type ConsoleSession, + type ExtensionBrowser, LINKABLE, type Linkable, type SignInMethods, call, consoleSessions, endConsoleSession, + endExtensionBrowser, + extensionBrowsers, linkAddress, renameMe, saveLanguage, @@ -16,6 +19,7 @@ import { signOutEverywhere, unlinkProvider, } from '../api'; +import { ago } from '../ago'; import { type Listed, edited } from '../listed'; import { problemOf } from '../problems'; import { Button, Choice, Field, LinkButton, Problem } from '../ui'; @@ -76,6 +80,15 @@ const DELETION_PROBLEMS: Readonly> = { }; const WORKSPACE_NOT_DELETED = msg('The workspace was not deleted. Try again.'); +/** « 12 Oct », as the mockup writes the day a browser connected. */ +function dayOf(iso: string): string { + const date = new Date(iso); + + return Number.isNaN(date.getTime()) + ? iso + : date.toLocaleDateString(locale() === 'fr' ? 'fr' : 'en-GB', { day: 'numeric', month: 'short' }); +} + /** A day and a time, in the language of the console. */ function moment(iso: string): string { const date = new Date(iso); @@ -146,12 +159,31 @@ export default function Account() { const [unsent, setUnsent] = useState(false); // The browsers where the console is signed in. No list is not an empty list (FRU-162). + // The browsers of the extension come in the same list, read together: the rows are drawn once, and + // nothing under them moves when the second answer comes (FRU-271). const [sessions, setSessions] = useState>('loading'); + const [extensions, setExtensions] = useState([]); const read = useCallback(async () => { setSessions('loading'); - const listed = await consoleSessions(); - setSessions(listed.ok ? listed.data : 'failed'); + const [listed, connected] = await Promise.all([consoleSessions(), extensionBrowsers()]); + if (!listed.ok || !connected.ok) return setSessions('failed'); + setExtensions(connected.data); + setSessions(listed.data); }, []); + const [extensionStuck, setExtensionStuck] = useState(); + + async function disconnectExtension(browser: ExtensionBrowser) { + setExtensionStuck(undefined); + const ended = await endExtensionBrowser(browser.id); + if (ended === 'failed') return setExtensionStuck(browser.id); + if (ended === 'unknown') { + // Ended since the list was read, or a worker with no such route: only the list says which. + const connected = await extensionBrowsers(); + if (!connected.ok || connected.data.some((each) => each.id === browser.id)) return setExtensionStuck(browser.id); + return setExtensions(connected.data); + } + setExtensions((current) => current.filter((each) => each.id !== browser.id)); + } useEffect(() => void read(), [read]); // The browser whose session did not end, by the id of its session. const [stuck, setStuck] = useState(); @@ -359,6 +391,36 @@ export default function Account() { ), )} + {[...extensions] + .sort((one, other) => other.usedAt.localeCompare(one.usedAt)) + .map((browser) => ( + + {t('This extension is still connected: Fruitback did not end its session. Try again.')} + + ) : undefined + } + > + void disconnectExtension(browser)} + /> + + ))} )} diff --git a/docs/decisions/console.md b/docs/decisions/console.md index ea2d9081..029b4989 100644 --- a/docs/decisions/console.md +++ b/docs/decisions/console.md @@ -312,7 +312,12 @@ The console of the Cloud, `apps/console`: its words, its kit, and what it shows taken from the address; « Unlink » asks twice, and the last way to sign in stays and says why (`409 last-sign-in`, `403 sign-in-again`). The three rows of « How you sign in » are drawn before the worker answers, so nothing under them moves. The worker keeps no login of a provider: « Linked », - not « Linked as ». No extension is listed yet: their route comes with FRU-238. The deletion of the + not « Linked as ». **The browsers of the extension are rows of the same list** (`GET +/console/me/browsers`, FRU-238): « Extension · Chrome on macOS », « Connected 12 Oct · used 2 h ago », + after the browsers of the console. The two lists are read together and drawn once, so nothing under + them moves when the second answer comes; a list that one of them could not read is no list. + « Disconnect » of an extension asks twice (it reconnects only with an approval here), and ends its + session on the worker (`DELETE /console/me/browsers/`): its next refresh is refused. The deletion of the workspace stays at the bottom, for its owner: the mockup does not draw it, and it is nowhere else. `e2e/console-account.spec.ts` holds the screen on stubs, and the sessions on a worker with accounts: one browser disconnects the other, which learns it at its next call, and « Sign out everywhere » ends every one. diff --git a/e2e/console-account.spec.ts b/e2e/console-account.spec.ts index a6c50349..8ec8e719 100644 --- a/e2e/console-account.spec.ts +++ b/e2e/console-account.spec.ts @@ -51,6 +51,10 @@ type Stubbed = { refuses: boolean; /** The sessions are ended already: the worker knows no session by these ids. */ gone: boolean; + /** The browsers of the extension (FRU-249); `undefined`: the worker does not answer. */ + extensions: unknown[] | undefined; + /** The worker ends no extension when it is asked to. */ + extensionRefuses: boolean; /** How the person signs in (FRU-249); `undefined`: the worker does not answer. */ methods: Record | undefined; /** What the worker answers to « Unlink », and to a name, when it refuses. */ @@ -70,6 +74,8 @@ async function workerWith(page: Page, role: Role = 'owner', locale: 'en' | 'fr' refuses: false, gone: false, methods: { email: true, github: { linked: true }, google: { linked: false } }, + extensions: [], + extensionRefuses: false, sent: [], bodies: {}, }; @@ -109,6 +115,19 @@ async function workerWith(page: Page, role: Role = 'owner', locale: 'en' | 'fr' return route.fulfill({ json: { account: { id: 'a1', email: 'ana@acme.dev', name: body.name } } }); } + if (pathname === '/console/me/browsers') { + return worker.extensions === undefined + ? route.fulfill({ status: 500, json: { error: 'unknown' } }) + : route.fulfill({ json: { browsers: worker.extensions } }); + } + if (pathname.startsWith('/console/me/browsers/')) { + worker.sent.push(`${method} ${pathname}`); + if (worker.extensionRefuses) return route.fulfill({ status: 502, json: { error: 'auth-unavailable' } }); + const id = pathname.split('/').at(-1); + worker.extensions = (worker.extensions ?? []).filter((each) => (each as { id: string }).id !== id); + + return route.fulfill({ status: 204 }); + } if (pathname === '/console/sessions') { return worker.sessions === undefined ? route.fulfill({ status: 500, json: { error: 'unknown' } }) @@ -605,6 +624,60 @@ test.describe('the browsers of a person, on a worker with accounts', () => { return { sites: sites.status, refresh: refreshed.status, next }; } + test('an extension connected to the account is listed, and « Disconnect » ends it on the worker', async ({ + browser, + }) => { + const first = await browserOf(browser, 'ana@acme.dev'); + const cookie = await cookieOf(first.context); + const consoleCall = (path: string, method = 'GET', body?: unknown) => + fetch(`${worker.origin}${path}`, { + method, + headers: { Origin: WORKER_CONSOLE, Cookie: cookie, 'Content-Type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + // The extension asks (FRU-238), the console approves with the word and the account, the extension + // claims its session. + const asked = await fetch(`${worker.origin}/session/connect`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ browser: 'Chrome on macOS' }), + }); + expect(asked.status).toBe(201); + const { request, claim, word } = (await asked.json()) as { request: string; claim: string; word: string }; + const me = (await (await consoleCall('/console/me')).json()) as { account: { id: string } }; + expect( + (await consoleCall(`/console/connect/${request}/approve`, 'POST', { word, account: me.account.id })).status, + ).toBe(200); + const claimed = await fetch(`${worker.origin}/session/connect/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ claim }), + }); + expect(claimed.status).toBe(200); + const session = (await claimed.json()) as { accessToken: string; refreshToken: string }; + + // My account lists it after the browsers of the console. + await first.page.getByRole('link', { name: 'My account' }).click(); + const browsers = first.page.getByRole('region', { name: 'Where you are signed in' }).getByRole('listitem'); + await expect(browsers).toHaveCount(2); + const extension = browsers.filter({ hasText: 'Extension · Chrome on macOS' }); + await expect(extension).toContainText(/Connected \d+ \w+ · used (Just now|\d+ min ago)/); + + // Disconnected in two presses: the row goes, and the worker refuses its refresh. + await extension.getByRole('button', { name: 'Disconnect', exact: true }).click(); + await extension.getByRole('button', { name: 'Disconnect the extension in Chrome on macOS' }).click(); + await expect(browsers).toHaveCount(1); + const refreshed = await fetch(`${worker.origin}/session/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: session.refreshToken }), + }); + expect(refreshed.status).toBe(401); + // The browser of the console is not the one that ended. + expect(await accepted(first.context)).toBe(200); + }); + test('the extension stays connected when My account disconnects a browser, and when it signs out everywhere', async ({ browser, }) => { @@ -783,6 +856,85 @@ test('a provider is « Not set up » when the worker has none, and the three row await expect(section(page, 'How you sign in').getByRole('link')).toHaveCount(0); }); +/** Noon of a fixed day: « used 2 h ago » is read on a clock of the browser set there. */ +const NOON = new Date(2026, 9, 13, 12, 0, 0); +const EXTENSIONS = [ + { + id: 'e1', + browser: 'Chrome on macOS', + connectedAt: new Date(2026, 9, 12, 9, 0).toISOString(), + usedAt: new Date(NOON.getTime() - 2 * 3_600_000).toISOString(), + }, + { + id: 'e2', + browser: 'Firefox on Linux', + connectedAt: new Date(2026, 9, 2, 9, 0).toISOString(), + usedAt: new Date(2026, 9, 10, 9, 0).toISOString(), + }, +]; + +test('the browsers of the extension are rows of the list, as the mockup draws them', async ({ page }) => { + await page.clock.setFixedTime(NOON); + const worker = await workerWith(page); + worker.extensions = EXTENSIONS; + await open(page); + const browsers = section(page, 'Where you are signed in').getByRole('listitem'); + // The browsers of the console first, then those of the extension, the one used last first. + await expect(browsers).toHaveCount(5); + await expect(browsers.nth(3)).toContainText('Extension · Chrome on macOS'); + await expect(browsers.nth(3)).toContainText('Connected 12 Oct · used 2 h ago'); + await expect(browsers.nth(4)).toContainText('Extension · Firefox on Linux'); + await expect(browsers.nth(4)).toContainText('Connected 2 Oct · used 3 days ago'); + // The row is the one of the console: its title at 600, its line at 13 px. + expect.soft(await styles(browsers.nth(3).getByText('Extension · Chrome on macOS'), ['font-weight'])).toEqual({ + 'font-weight': '600', + }); + expect.soft(await styles(browsers.nth(3).getByText(/^Connected/), ['font-size'])).toEqual({ 'font-size': '13px' }); + await expectNoViolation(page, 'the browsers of the extension'); +}); + +test('an extension is disconnected in two presses, and one that is still connected says so', async ({ page }) => { + await page.clock.setFixedTime(NOON); + const worker = await workerWith(page); + worker.extensions = EXTENSIONS; + await open(page); + const browsers = section(page, 'Where you are signed in').getByRole('listitem'); + const chrome = browsers.filter({ hasText: 'Extension · Chrome on macOS' }); + + worker.extensionRefuses = true; + await chrome.getByRole('button', { name: 'Disconnect', exact: true }).click(); + await expect(chrome).toContainText('It stops showing notes at once.'); + await chrome.getByRole('button', { name: 'Disconnect the extension in Chrome on macOS' }).click(); + await expect(chrome.getByRole('alert')).toHaveText( + 'This extension is still connected: Fruitback did not end its session. Try again.', + ); + await expect(browsers).toHaveCount(5); + + worker.extensionRefuses = false; + await chrome.getByRole('button', { name: 'Disconnect the extension in Chrome on macOS' }).click(); + await expect(browsers).toHaveCount(4); + await expect(page.getByText('Extension · Chrome on macOS')).toHaveCount(0); + expect(worker.sent.filter((each) => each.includes('/me/browsers/'))).toEqual([ + 'DELETE /console/me/browsers/e1', + 'DELETE /console/me/browsers/e1', + ]); +}); + +test('the browsers of the extension come with those of the console: « Sign out » does not move twice', async ({ + page, +}) => { + const worker = await workerWith(page); + // A list of the extension that the worker could not read is no list: the section says so. + worker.extensions = undefined; + await page.goto(`${CONSOLE}/w/w1/account`); + await expect( + section(page, 'Where you are signed in').getByText( + 'Fruitback did not answer. The browsers where you are signed in could not be read.', + ), + ).toBeVisible(); + await expect(section(page, 'Where you are signed in').getByRole('listitem')).toHaveCount(0); +}); + test('a browser is named as the worker names it', async ({ page }) => { const worker = await workerWith(page); worker.sessions = [ From 22e742724e5df172281b19e0fdba5dd2193643fb Mon Sep 17 00:00:00 2001 From: Mathieu Audebert Date: Wed, 14 Oct 2026 16:32:04 +0200 Subject: [PATCH 2/2] fix(console): the browsers of the console stay when those of the extension cannot be read (FRU-249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the balanced review: a failed read of /console/me/browsers made the whole list fail, and took « Disconnect » of the browsers of the console with it. The browsers of the console now stay, and a line says that those of the extension could not be read, with « Try again ». A disconnection answered 404 whose read again fails says the same, and no longer that the extension is still connected (lite review). The test fails on the head before. Co-Authored-By: Claude Opus 5.5 --- apps/console/app/messages.fr.ts | 2 ++ apps/console/app/routes/account.tsx | 23 +++++++++++++++++++---- docs/decisions/console.md | 4 +++- e2e/console-account.spec.ts | 23 +++++++++++++---------- 4 files changed, 37 insertions(+), 15 deletions(-) diff --git a/apps/console/app/messages.fr.ts b/apps/console/app/messages.fr.ts index bd2fd57b..ae0836ce 100644 --- a/apps/console/app/messages.fr.ts +++ b/apps/console/app/messages.fr.ts @@ -671,6 +671,8 @@ export const FRENCH: Readonly> = { 'Disconnect the extension in {browser}': 'Déconnecter l’extension dans {browser}', 'It stops showing notes at once. It connects again from its popup, with your approval here.': 'Elle cesse d’afficher les notes tout de suite. Elle se reconnecte depuis son popup, avec votre accord ici.', + 'Fruitback did not answer. The browsers of the extension could not be read.': + 'Fruitback n’a pas répondu. Les navigateurs de l’extension n’ont pas pu être lus.', 'This extension is still connected: Fruitback did not end its session. Try again.': 'Cette extension est toujours connectée : Fruitback n’a pas mis fin à sa session. Réessayez.', }; diff --git a/apps/console/app/routes/account.tsx b/apps/console/app/routes/account.tsx index 83a1c52d..a0a00728 100644 --- a/apps/console/app/routes/account.tsx +++ b/apps/console/app/routes/account.tsx @@ -162,13 +162,16 @@ export default function Account() { // The browsers of the extension come in the same list, read together: the rows are drawn once, and // nothing under them moves when the second answer comes (FRU-271). const [sessions, setSessions] = useState>('loading'); + // The browsers of the extension that could not be read do not take the browsers of the console + // with them: those stay, with their « Disconnect », and a line says what is missing. const [extensions, setExtensions] = useState([]); + const [extensionsFailed, setExtensionsFailed] = useState(false); const read = useCallback(async () => { setSessions('loading'); const [listed, connected] = await Promise.all([consoleSessions(), extensionBrowsers()]); - if (!listed.ok || !connected.ok) return setSessions('failed'); - setExtensions(connected.data); - setSessions(listed.data); + setExtensions(connected.ok ? connected.data : []); + setExtensionsFailed(!connected.ok); + setSessions(listed.ok ? listed.data : 'failed'); }, []); const [extensionStuck, setExtensionStuck] = useState(); @@ -179,7 +182,9 @@ export default function Account() { if (ended === 'unknown') { // Ended since the list was read, or a worker with no such route: only the list says which. const connected = await extensionBrowsers(); - if (!connected.ok || connected.data.some((each) => each.id === browser.id)) return setExtensionStuck(browser.id); + // A list that could not be read says nothing of this browser: the line of a failed read says so. + if (!connected.ok) return setExtensionsFailed(true); + if (connected.data.some((each) => each.id === browser.id)) return setExtensionStuck(browser.id); return setExtensions(connected.data); } setExtensions((current) => current.filter((each) => each.id !== browser.id)); @@ -421,6 +426,16 @@ export default function Account() { /> ))} + {extensionsFailed ? ( +
  • +

    + {t('Fruitback did not answer. The browsers of the extension could not be read.')} +

    + +
  • + ) : null} )} diff --git a/docs/decisions/console.md b/docs/decisions/console.md index 029b4989..7a24f845 100644 --- a/docs/decisions/console.md +++ b/docs/decisions/console.md @@ -315,7 +315,9 @@ The console of the Cloud, `apps/console`: its words, its kit, and what it shows not « Linked as ». **The browsers of the extension are rows of the same list** (`GET /console/me/browsers`, FRU-238): « Extension · Chrome on macOS », « Connected 12 Oct · used 2 h ago », after the browsers of the console. The two lists are read together and drawn once, so nothing under - them moves when the second answer comes; a list that one of them could not read is no list. + them moves when the second answer comes. A list of the console that could not be read is no list; a + list of the extension that could not be read leaves the browsers of the console and their + « Disconnect », with a line that says what is missing and « Try again ». « Disconnect » of an extension asks twice (it reconnects only with an approval here), and ends its session on the worker (`DELETE /console/me/browsers/`): its next refresh is refused. The deletion of the workspace stays at the bottom, for its owner: the mockup does not draw it, and it is nowhere else. diff --git a/e2e/console-account.spec.ts b/e2e/console-account.spec.ts index 8ec8e719..3814387d 100644 --- a/e2e/console-account.spec.ts +++ b/e2e/console-account.spec.ts @@ -920,19 +920,22 @@ test('an extension is disconnected in two presses, and one that is still connect ]); }); -test('the browsers of the extension come with those of the console: « Sign out » does not move twice', async ({ - page, -}) => { +test('a list of the extension that was not read leaves the browsers of the console, and says so', async ({ page }) => { const worker = await workerWith(page); - // A list of the extension that the worker could not read is no list: the section says so. worker.extensions = undefined; await page.goto(`${CONSOLE}/w/w1/account`); - await expect( - section(page, 'Where you are signed in').getByText( - 'Fruitback did not answer. The browsers where you are signed in could not be read.', - ), - ).toBeVisible(); - await expect(section(page, 'Where you are signed in').getByRole('listitem')).toHaveCount(0); + const where = section(page, 'Where you are signed in'); + // The browsers of the console are there, with their « Disconnect ». + await expect(where.getByRole('listitem').filter({ hasText: 'Console · this browser' })).toBeVisible(); + await expect(where.getByRole('button', { name: 'Disconnect', exact: true })).toHaveCount(2); + // And a line says what is missing, with the way to read it again. + await expect(where.getByRole('alert')).toHaveText( + 'Fruitback did not answer. The browsers of the extension could not be read.', + ); + worker.extensions = EXTENSIONS; + await where.getByRole('button', { name: 'Try again' }).click(); + await expect(where.getByText('Extension · Chrome on macOS')).toBeVisible(); + await expect(where.getByRole('alert')).toHaveCount(0); }); test('a browser is named as the worker names it', async ({ page }) => {