diff --git a/src/locales/en.json b/src/locales/en.json index b75881dd04b..606e6d587f4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -225,6 +225,7 @@ "index.copyLink": "2. Copy link", "index.copyLinkDescription": "Click on the button below to copy the link to your clipboard.", "index.copyLinkButton": "Copy link to clipboard", + "index.sessionTransferError": "Unable to transfer the session. Please try again.", "index.transferToSystem": "3. Copy session to new system", "index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.", "index.code": "Code", diff --git a/src/static/js/welcome.ts b/src/static/js/welcome.ts index 7d510c9d4a8..a3efe23eae8 100644 --- a/src/static/js/welcome.ts +++ b/src/static/js/welcome.ts @@ -1,3 +1,5 @@ +import html10n from './vendors/html10n'; + const checkmark = ''; function getCookie(name: string) { @@ -11,46 +13,137 @@ function getCookie(name: string) { const cp = (window as any).clientVars?.cookiePrefix || ''; +const sessionTransferErrorFallback = () => + html10n.get('index.sessionTransferError') || 'Unable to transfer the session. Please try again.'; + +const safeJson = async (response: Response): Promise => { + try { + return await response.json(); + } catch { + return null; + } +}; + +const responseErrorMessage = (responseData: unknown): string => { + const data = responseData as Record; + if ( + responseData && + typeof responseData === 'object' && + 'error' in responseData && + typeof data.error === 'string' && + data.error.trim() !== '' + ) { + return data.error; + } + return sessionTransferErrorFallback(); +}; + +const showSessionTransferError = (element: HTMLElement | null, message: string) => { + if (!element) return; + element.textContent = message; + element.style.display = 'block'; +}; + +const hideSessionTransferError = (element: HTMLElement | null) => { + if (!element) return; + element.textContent = ''; + element.style.display = 'none'; +}; + function handleTransferOfSession() { const transferNowButton = document.querySelector('[data-l10n-id="index.transferSessionNow"]')! as HTMLButtonElement; transferNowButton.addEventListener('click', async () => { + const originalButtonContent = transferNowButton.innerHTML; + const copyLinkSection = document.getElementById('copy-link-section'); + const errorElement = document.getElementById('transfer-session-error'); + hideSessionTransferError(errorElement); + if (copyLinkSection) copyLinkSection.style.display = 'none'; transferNowButton.style.display = 'inline-flex'; transferNowButton.style.alignItems = 'center'; transferNowButton.style.justifyContent = 'center'; - transferNowButton.innerHTML = `${checkmark}`; transferNowButton.disabled = true; - // The author token is HttpOnly (ether/etherpad#6701 PR3) so we cannot - // read it via document.cookie. Send only the JS-readable prefsHttp; the - // server reads the token off the request's own cookie jar. - const responseWithId = await fetch("./tokenTransfer", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - prefsHttp: getCookie(`${cp}prefsHttp`) || getCookie('prefsHttp'), - }) - }) + try { + // The author token is HttpOnly (ether/etherpad#6701 PR3) so we cannot + // read it via document.cookie. Send only the JS-readable prefsHttp; the + // server reads the token off the request's own cookie jar. + const responseWithId = await fetch("./tokenTransfer", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + prefsHttp: getCookie(`${cp}prefsHttp`) || getCookie('prefsHttp'), + }) + }); - const copyLinkSection = document.getElementById('copy-link-section') - if (!copyLinkSection) return; - copyLinkSection.style.display = 'block'; - - const copyButton = document.querySelector('#copy-link-section .btn-secondary') as HTMLButtonElement - const responseData = await responseWithId.json(); - copyButton.addEventListener('click', async ()=>{ - await navigator.clipboard.writeText(responseData.id); - copyButton.style.display = 'inline-flex'; - copyButton.style.alignItems = 'center'; - copyButton.style.justifyContent = 'center'; - copyButton.innerHTML = `${checkmark}`; - copyButton.disabled = true; - }) + const responseData = await safeJson(responseWithId); + if (!responseWithId.ok) { + throw new Error(responseErrorMessage(responseData)); + } + const transferData = responseData as Record; + if (!responseData || typeof responseData !== 'object' || + !('id' in responseData) || typeof transferData.id !== 'string' || + transferData.id.trim() === '') { + throw new Error(sessionTransferErrorFallback()); + } + + if (!copyLinkSection) throw new Error(sessionTransferErrorFallback()); + copyLinkSection.style.display = 'block'; + + const copyButton = document.querySelector('#copy-link-section .btn-secondary') as HTMLButtonElement; + copyButton.disabled = false; + copyButton.onclick = async () => { + await navigator.clipboard.writeText(transferData.id as string); + copyButton.style.display = 'inline-flex'; + copyButton.style.alignItems = 'center'; + copyButton.style.justifyContent = 'center'; + copyButton.innerHTML = `${checkmark}`; + copyButton.disabled = true; + }; + transferNowButton.innerHTML = `${checkmark}`; + } catch (err) { + if (copyLinkSection) copyLinkSection.style.display = 'none'; + transferNowButton.innerHTML = originalButtonContent; + transferNowButton.disabled = false; + showSessionTransferError( + errorElement, + err instanceof Error && err.message ? err.message : sessionTransferErrorFallback()); + } }); } +const isValidTransferCode = (code: string) => code.length === 36; + +async function redeemTransferCode( + code: string, + transferSessionButton: HTMLButtonElement, + errorElement: HTMLElement | null) { + hideSessionTransferError(errorElement); + transferSessionButton.disabled = true; + + try { + const response = await fetch("./tokenTransfer/"+code, { + method: 'GET' + }); + const responseData = await safeJson(response); + if (!response.ok) { + throw new Error(responseErrorMessage(responseData)); + } + const transferData = responseData as Record; + if (!responseData || typeof responseData !== 'object' || + !('ok' in responseData) || transferData.ok !== true) { + throw new Error(sessionTransferErrorFallback()); + } + window.location.reload() + } catch (err) { + transferSessionButton.disabled = !isValidTransferCode(code); + showSessionTransferError( + errorElement, + err instanceof Error && err.message ? err.message : sessionTransferErrorFallback()); + } +} const handleSettingsButtonClick = () => { const settingsButton = document.querySelector('.settings-button')!; @@ -86,24 +179,22 @@ const handleMenuBarClicked = () => { }); }) - const transferSessionButton = document.getElementById('transferSessionButton') + const transferSessionButton = document.getElementById('transferSessionButton') as HTMLButtonElement | null; const codeInputField = document.getElementById('codeInput') as HTMLInputElement if (transferSessionButton) { transferSessionButton.addEventListener('click', ()=>{ - const code = codeInputField.value - fetch("./tokenTransfer/"+code, { - method: 'GET' - }) - .then(res => res.json()) - .then(()=>{ - window.location.reload() - }) + const code = codeInputField.value; + redeemTransferCode( + code, + transferSessionButton, + document.getElementById('receive-session-error')); }); } if (codeInputField) { codeInputField.addEventListener('input', (e)=>{ - if ((e.target as HTMLInputElement).value?.length === 36) { + hideSessionTransferError(document.getElementById('receive-session-error')); + if (isValidTransferCode((e.target as HTMLInputElement).value)) { transferSessionButton?.removeAttribute('disabled'); } else { transferSessionButton?.setAttribute('disabled', 'true'); diff --git a/src/templates/index.html b/src/templates/index.html index dfb43c3d36a..8f514f332a5 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -184,6 +184,7 @@

Etherpad

+ +
diff --git a/src/tests/frontend-new/specs/welcome.spec.test.ts b/src/tests/frontend-new/specs/welcome.spec.test.ts deleted file mode 100644 index 7e24004624c..00000000000 --- a/src/tests/frontend-new/specs/welcome.spec.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { test, expect } from '@playwright/test'; - -test.describe('Session Transfer Functionality', () => { - test.beforeEach(async ({ page, context }) => { - await context.addCookies([ - { - name: 'token', - value: 'test-token-123', - domain: 'localhost', - path: '/', - }, - { - name: 'prefsHttp', - value: 'test-prefs', - domain: 'localhost', - path: '/', - }, - ]); - - await page.goto('localhost:9001/'); - }); - - test('should open settings dialog and transfer session', async ({ - page, - }) => { - await page.route('**/tokenTransfer', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'transfer-id-12345678-1234-5678' }), - }); - }); - - await page.locator('.settings-button').click(); - const dialog = page.locator('#settings-dialog'); - await expect(dialog).toBeVisible(); - - const transferButton = page.locator( - '[data-l10n-id="index.transferSessionNow"]' - ); - await expect(transferButton).toBeVisible(); - - await transferButton.click(); - - await expect(transferButton).toBeDisabled(); - await expect(transferButton.locator('svg')).toBeVisible(); - - const copyLinkSection = page.locator('#copy-link-section'); - await expect(copyLinkSection).toBeVisible(); - - const copyButton = copyLinkSection.locator('.btn-secondary'); - await expect(copyButton).toBeVisible(); - }); - - test('should copy transfer ID to clipboard', async ({ page }) => { - const transferId = 'abc123-transfer-id-xyz789'; - - await page.route('**/tokenTransfer', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: transferId }), - }); - }); - - await page.locator('.settings-button').click(); - await page - .locator('[data-l10n-id="index.transferSessionNow"]') - .click(); - - const copyButton = page.locator('#copy-link-section .btn-secondary'); - await expect(copyButton).toBeVisible(); - - await page.evaluate(() => { - // @ts-ignore - window.clipboardData = ''; - navigator.clipboard.writeText = async (text: string) => { - // @ts-ignore - window.clipboardData = text; - return Promise.resolve(); - }; - }); - - await copyButton.click(); - - await expect(copyButton).toBeDisabled(); - await expect(copyButton.locator('svg')).toBeVisible(); - - const clipboardText = await page.evaluate( - // @ts-ignore - () => window.clipboardData - ); - expect(clipboardText).toBe(transferId); - }); - - test('should receive session with valid code', async ({ page }) => { - const validCode = '12345678-1234-5678-1234-567812345678'; - - await page.route(`**/tokenTransfer/${validCode}`, async (route) => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ success: true }), - }); - }); - - await page.locator('.settings-button').click(); - - await page - .locator('#button-bar button[data-l10n-id="index.receiveSessionTitle"]') - .click(); - - const receiveSection = page.locator('#transfer-to-system-section'); - await expect(receiveSection).toBeVisible(); - - const codeInput = page.locator('#codeInput'); - await expect(codeInput).toBeVisible(); - - const transferButton = page.locator('#transferSessionButton'); - await expect(transferButton).toBeDisabled(); - - await codeInput.fill(validCode); - - await expect(transferButton).not.toBeDisabled(); - - await Promise.all([ - page.waitForNavigation(), - transferButton.click(), - ]); - }); - - test('should keep transfer button disabled for invalid code length', async ({ - page, - }) => { - await page.locator('.settings-button').click(); - - await page - .locator('#button-bar button[data-l10n-id="index.receiveSessionTitle"]') - .click(); - - const codeInput = page.locator('#codeInput'); - const transferButton = page.locator('#transferSessionButton'); - - await codeInput.fill('short-code'); - await expect(transferButton).toBeDisabled(); - - await codeInput.fill( - '12345678-1234-5678-1234-567812345678-extra' - ); - await expect(transferButton).toBeDisabled(); - - await codeInput.fill(''); - await expect(transferButton).toBeDisabled(); - }); - - test('should switch between tabs in settings dialog', async ({ - page, - }) => { - await page.locator('.settings-button').click(); - - const transferTab = page.locator( - '#button-bar button[data-l10n-id="index.transferSessionTitle"]' - ); - const receiveTab = page.locator( - '#button-bar button[data-l10n-id="index.receiveSessionTitle"]' - ); - - await expect(transferTab).toHaveClass(/active-btn/); - - await receiveTab.click(); - await expect(receiveTab).toHaveClass(/active-btn/); - await expect(transferTab).not.toHaveClass(/active-btn/); - - await expect( - page.locator('#transfer-to-system-section') - ).toBeVisible(); - - await transferTab.click(); - await expect(transferTab).toHaveClass(/active-btn/); - }); - - test('should close dialog when clicking outside', async ({ page }) => { - await page.locator('.settings-button').click(); - const dialog = page.locator('#settings-dialog'); - - await expect(dialog).toBeVisible(); - - await dialog.evaluate((el) => (el as HTMLElement).click()); - - await expect(dialog).not.toBeVisible(); - }); -}); diff --git a/src/tests/frontend-new/specs/welcome.spec.ts b/src/tests/frontend-new/specs/welcome.spec.ts new file mode 100644 index 00000000000..6b64db3ab71 --- /dev/null +++ b/src/tests/frontend-new/specs/welcome.spec.ts @@ -0,0 +1,353 @@ +import {type Page, test, expect} from '@playwright/test'; + +test.describe('Session Transfer Functionality', () => { + const validCode = '12345678-1234-5678-1234-567812345678'; + + test.beforeEach(async ({ page, context }) => { + await context.addCookies([ + { + name: 'token', + value: 'test-token-123', + domain: 'localhost', + path: '/', + }, + { + name: 'prefsHttp', + value: 'test-prefs', + domain: 'localhost', + path: '/', + }, + ]); + + await page.goto('localhost:9001/'); + }); + + const openSettingsDialog = async (page: Page) => { + await page.locator('.settings-button').click(); + const dialog = page.locator('#settings-dialog'); + await expect(dialog).toBeVisible(); + return dialog; + }; + + const openReceiveSession = async (page: Page) => { + await openSettingsDialog(page); + await page + .locator('#button-bar button[data-l10n-id="index.receiveSessionTitle"]') + .click(); + }; + + test('should open settings dialog and transfer session', async ({ + page, + }) => { + await page.route('**/tokenTransfer', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'transfer-id-12345678-1234-5678' }), + }); + }); + + await openSettingsDialog(page); + + const transferButton = page.locator( + '[data-l10n-id="index.transferSessionNow"]' + ); + await expect(transferButton).toBeVisible(); + + await transferButton.click(); + + await expect(transferButton).toBeDisabled(); + await expect(transferButton.locator('svg')).toBeVisible(); + + const copyLinkSection = page.locator('#copy-link-section'); + await expect(copyLinkSection).toBeVisible(); + + const copyButton = copyLinkSection.locator('.btn-secondary'); + await expect(copyButton).toBeVisible(); + }); + + test('should keep create transfer controls usable after a 400 response', async ({ page }) => { + await page.route('**/tokenTransfer', async (route) => { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ error: 'No author cookie to transfer' }), + }); + }); + + await openSettingsDialog(page); + + const transferButton = page.locator('[data-l10n-id="index.transferSessionNow"]'); + await transferButton.click(); + + await expect(page.locator('#copy-link-section')).toBeHidden(); + await expect(transferButton).not.toBeDisabled(); + await expect(transferButton.locator('svg')).toHaveCount(0); + await expect(page.locator('#transfer-session-error')) + .toHaveText('No author cookie to transfer'); + }); + + test('should render create transfer server errors as text', async ({ page }) => { + const serverError = 'Token failed'; + await page.route('**/tokenTransfer', async (route) => { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ error: serverError }), + }); + }); + + await openSettingsDialog(page); + await page.locator('[data-l10n-id="index.transferSessionNow"]').click(); + + const error = page.locator('#transfer-session-error'); + await expect(error).toHaveText(serverError); + await expect(error.locator('img')).toHaveCount(0); + expect(await page.evaluate(() => (window as any).sessionTransferXss)).toBeUndefined(); + }); + + test('should treat create transfer 2xx without an id as a failure', async ({ page }) => { + await page.route('**/tokenTransfer', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + }); + + await openSettingsDialog(page); + + const transferButton = page.locator('[data-l10n-id="index.transferSessionNow"]'); + await transferButton.click(); + + await expect(page.locator('#copy-link-section')).toBeHidden(); + await expect(transferButton).not.toBeDisabled(); + await expect(page.locator('#transfer-session-error')).toBeVisible(); + }); + + test('should not treat a non-2xx create response with an id as success', async ({ page }) => { + await page.route('**/tokenTransfer', async (route) => { + await route.fulfill({ + status: 400, + contentType: 'application/json', + body: JSON.stringify({ id: 'transfer-id-from-error-response' }), + }); + }); + + await openSettingsDialog(page); + + const transferButton = page.locator('[data-l10n-id="index.transferSessionNow"]'); + await transferButton.click(); + + await expect(page.locator('#copy-link-section')).toBeHidden(); + await expect(transferButton).not.toBeDisabled(); + await expect(page.locator('#transfer-session-error')).toBeVisible(); + }); + + test('should not expose copy state after a create transfer network failure', async ({ page }) => { + await page.route('**/tokenTransfer', async (route) => route.abort()); + + await openSettingsDialog(page); + + const transferButton = page.locator('[data-l10n-id="index.transferSessionNow"]'); + await transferButton.click(); + + await expect(page.locator('#copy-link-section')).toBeHidden(); + await expect(transferButton).not.toBeDisabled(); + await expect(page.locator('#transfer-session-error')).toBeVisible(); + }); + + test('should copy transfer ID to clipboard', async ({ page }) => { + const transferId = 'abc123-transfer-id-xyz789'; + + await page.route('**/tokenTransfer', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: transferId }), + }); + }); + + await openSettingsDialog(page); + await page + .locator('[data-l10n-id="index.transferSessionNow"]') + .click(); + + const copyButton = page.locator('#copy-link-section .btn-secondary'); + await expect(copyButton).toBeVisible(); + + await page.evaluate(() => { + // @ts-ignore + window.clipboardData = ''; + navigator.clipboard.writeText = async (text: string) => { + // @ts-ignore + window.clipboardData = text; + return Promise.resolve(); + }; + }); + + await copyButton.click(); + + await expect(copyButton).toBeDisabled(); + await expect(copyButton.locator('svg')).toBeVisible(); + + const clipboardText = await page.evaluate( + // @ts-ignore + () => window.clipboardData + ); + expect(clipboardText).toBe(transferId); + }); + + test('should receive session with valid code', async ({ page }) => { + await page.route(`**/tokenTransfer/${validCode}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ ok: true }), + }); + }); + + await openReceiveSession(page); + + const receiveSection = page.locator('#transfer-to-system-section'); + await expect(receiveSection).toBeVisible(); + + const codeInput = page.locator('#codeInput'); + await expect(codeInput).toBeVisible(); + + const transferButton = page.locator('#transferSessionButton'); + await expect(transferButton).toBeDisabled(); + + await codeInput.fill(validCode); + + await expect(transferButton).not.toBeDisabled(); + + await Promise.all([ + page.waitForNavigation(), + transferButton.click(), + ]); + }); + + test('should not reload and should preserve code after a missing transfer id', async ({ page }) => { + await page.route(`**/tokenTransfer/${validCode}`, async (route) => { + await route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: 'Token not found' }), + }); + }); + + await openReceiveSession(page); + + const codeInput = page.locator('#codeInput'); + const transferButton = page.locator('#transferSessionButton'); + await codeInput.fill(validCode); + await transferButton.click(); + + await expect(page.locator('#settings-dialog')).toBeVisible(); + await expect(codeInput).toHaveValue(validCode); + await expect(transferButton).not.toBeDisabled(); + await expect(page.locator('#receive-session-error')).toHaveText('Token not found'); + }); + + test('should not reload and should preserve code after an expired transfer id', async ({ page }) => { + await page.route(`**/tokenTransfer/${validCode}`, async (route) => { + await route.fulfill({ + status: 410, + contentType: 'application/json', + body: JSON.stringify({ error: 'Token expired' }), + }); + }); + + await openReceiveSession(page); + + const codeInput = page.locator('#codeInput'); + const transferButton = page.locator('#transferSessionButton'); + await codeInput.fill(validCode); + await transferButton.click(); + + await expect(page.locator('#settings-dialog')).toBeVisible(); + await expect(codeInput).toHaveValue(validCode); + await expect(transferButton).not.toBeDisabled(); + await expect(page.locator('#receive-session-error')).toHaveText('Token expired'); + }); + + test('should not reload when receive transfer response is not valid JSON', async ({ page }) => { + await page.route(`**/tokenTransfer/${validCode}`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/plain', + body: 'not-json', + }); + }); + + await openReceiveSession(page); + + const codeInput = page.locator('#codeInput'); + const transferButton = page.locator('#transferSessionButton'); + await codeInput.fill(validCode); + await transferButton.click(); + + await expect(page.locator('#settings-dialog')).toBeVisible(); + await expect(codeInput).toHaveValue(validCode); + await expect(transferButton).not.toBeDisabled(); + await expect(page.locator('#receive-session-error')).toBeVisible(); + }); + + test('should keep transfer button disabled for invalid code length', async ({ + page, + }) => { + await openReceiveSession(page); + + const codeInput = page.locator('#codeInput'); + const transferButton = page.locator('#transferSessionButton'); + + await codeInput.fill('short-code'); + await expect(transferButton).toBeDisabled(); + + await codeInput.fill( + '12345678-1234-5678-1234-567812345678-extra' + ); + await expect(transferButton).toBeDisabled(); + + await codeInput.fill(''); + await expect(transferButton).toBeDisabled(); + }); + + test('should switch between tabs in settings dialog', async ({ + page, + }) => { + await openSettingsDialog(page); + + const transferTab = page.locator( + '#button-bar button[data-l10n-id="index.transferSessionTitle"]' + ); + const receiveTab = page.locator( + '#button-bar button[data-l10n-id="index.receiveSessionTitle"]' + ); + + await expect(transferTab).toHaveClass(/active-btn/); + + await receiveTab.click(); + await expect(receiveTab).toHaveClass(/active-btn/); + await expect(transferTab).not.toHaveClass(/active-btn/); + + await expect( + page.locator('#transfer-to-system-section') + ).toBeVisible(); + + await transferTab.click(); + await expect(transferTab).toHaveClass(/active-btn/); + }); + + test('should close dialog when clicking outside', async ({ page }) => { + await openSettingsDialog(page); + const dialog = page.locator('#settings-dialog'); + + await expect(dialog).toBeVisible(); + + await dialog.evaluate((el) => (el as HTMLElement).click()); + + await expect(dialog).not.toBeVisible(); + }); +});