Skip to content

Commit dbd6b51

Browse files
committed
frontend: handle session transfer request failures
Only treat a session transfer as successful after receiving a successful response with the expected payload, and restore usable controls on errors. Fixes #8172
1 parent ba2bbd3 commit dbd6b51

5 files changed

Lines changed: 484 additions & 229 deletions

File tree

src/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@
225225
"index.copyLink": "2. Copy link",
226226
"index.copyLinkDescription": "Click on the button below to copy the link to your clipboard.",
227227
"index.copyLinkButton": "Copy link to clipboard",
228+
"index.sessionTransferError": "Unable to transfer the session. Please try again.",
228229
"index.transferToSystem": "3. Copy session to new system",
229230
"index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.",
230231
"index.code": "Code",

src/static/js/welcome.ts

Lines changed: 128 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import html10n from './vendors/html10n';
2+
13
const checkmark = '<svg width="28" height="28" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor"><path vector-effect="non-scaling-stroke" stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>';
24

35
function getCookie(name: string) {
@@ -11,46 +13,137 @@ function getCookie(name: string) {
1113

1214
const cp = (window as any).clientVars?.cookiePrefix || '';
1315

16+
const sessionTransferErrorFallback = () =>
17+
html10n.get('index.sessionTransferError') || 'Unable to transfer the session. Please try again.';
18+
19+
const safeJson = async (response: Response): Promise<unknown> => {
20+
try {
21+
return await response.json();
22+
} catch {
23+
return null;
24+
}
25+
};
26+
27+
const responseErrorMessage = (responseData: unknown): string => {
28+
const data = responseData as Record<string, unknown>;
29+
if (
30+
responseData &&
31+
typeof responseData === 'object' &&
32+
'error' in responseData &&
33+
typeof data.error === 'string' &&
34+
data.error.trim() !== ''
35+
) {
36+
return data.error;
37+
}
38+
return sessionTransferErrorFallback();
39+
};
40+
41+
const showSessionTransferError = (element: HTMLElement | null, message: string) => {
42+
if (!element) return;
43+
element.textContent = message;
44+
element.style.display = 'block';
45+
};
46+
47+
const hideSessionTransferError = (element: HTMLElement | null) => {
48+
if (!element) return;
49+
element.textContent = '';
50+
element.style.display = 'none';
51+
};
52+
1453
function handleTransferOfSession() {
1554
const transferNowButton = document.querySelector('[data-l10n-id="index.transferSessionNow"]')! as HTMLButtonElement;
1655

1756
transferNowButton.addEventListener('click', async () => {
57+
const originalButtonContent = transferNowButton.innerHTML;
58+
const copyLinkSection = document.getElementById('copy-link-section');
59+
const errorElement = document.getElementById('transfer-session-error');
60+
hideSessionTransferError(errorElement);
61+
if (copyLinkSection) copyLinkSection.style.display = 'none';
1862
transferNowButton.style.display = 'inline-flex';
1963
transferNowButton.style.alignItems = 'center';
2064
transferNowButton.style.justifyContent = 'center';
21-
transferNowButton.innerHTML = `${checkmark}`;
2265
transferNowButton.disabled = true;
2366

24-
// The author token is HttpOnly (ether/etherpad#6701 PR3) so we cannot
25-
// read it via document.cookie. Send only the JS-readable prefsHttp; the
26-
// server reads the token off the request's own cookie jar.
27-
const responseWithId = await fetch("./tokenTransfer", {
28-
method: "POST",
29-
headers: {
30-
"Content-Type": "application/json"
31-
},
32-
body: JSON.stringify({
33-
prefsHttp: getCookie(`${cp}prefsHttp`) || getCookie('prefsHttp'),
34-
})
35-
})
67+
try {
68+
// The author token is HttpOnly (ether/etherpad#6701 PR3) so we cannot
69+
// read it via document.cookie. Send only the JS-readable prefsHttp; the
70+
// server reads the token off the request's own cookie jar.
71+
const responseWithId = await fetch("./tokenTransfer", {
72+
method: "POST",
73+
headers: {
74+
"Content-Type": "application/json"
75+
},
76+
body: JSON.stringify({
77+
prefsHttp: getCookie(`${cp}prefsHttp`) || getCookie('prefsHttp'),
78+
})
79+
});
3680

37-
const copyLinkSection = document.getElementById('copy-link-section')
38-
if (!copyLinkSection) return;
39-
copyLinkSection.style.display = 'block';
40-
41-
const copyButton = document.querySelector('#copy-link-section .btn-secondary') as HTMLButtonElement
42-
const responseData = await responseWithId.json();
43-
copyButton.addEventListener('click', async ()=>{
44-
await navigator.clipboard.writeText(responseData.id);
45-
copyButton.style.display = 'inline-flex';
46-
copyButton.style.alignItems = 'center';
47-
copyButton.style.justifyContent = 'center';
48-
copyButton.innerHTML = `${checkmark}`;
49-
copyButton.disabled = true;
50-
})
81+
const responseData = await safeJson(responseWithId);
82+
if (!responseWithId.ok) {
83+
throw new Error(responseErrorMessage(responseData));
84+
}
85+
const transferData = responseData as Record<string, unknown>;
86+
if (!responseData || typeof responseData !== 'object' ||
87+
!('id' in responseData) || typeof transferData.id !== 'string' ||
88+
transferData.id.trim() === '') {
89+
throw new Error(sessionTransferErrorFallback());
90+
}
91+
92+
if (!copyLinkSection) throw new Error(sessionTransferErrorFallback());
93+
copyLinkSection.style.display = 'block';
94+
95+
const copyButton = document.querySelector('#copy-link-section .btn-secondary') as HTMLButtonElement;
96+
copyButton.disabled = false;
97+
copyButton.onclick = async () => {
98+
await navigator.clipboard.writeText(transferData.id as string);
99+
copyButton.style.display = 'inline-flex';
100+
copyButton.style.alignItems = 'center';
101+
copyButton.style.justifyContent = 'center';
102+
copyButton.innerHTML = `${checkmark}`;
103+
copyButton.disabled = true;
104+
};
105+
transferNowButton.innerHTML = `${checkmark}`;
106+
} catch (err) {
107+
if (copyLinkSection) copyLinkSection.style.display = 'none';
108+
transferNowButton.innerHTML = originalButtonContent;
109+
transferNowButton.disabled = false;
110+
showSessionTransferError(
111+
errorElement,
112+
err instanceof Error && err.message ? err.message : sessionTransferErrorFallback());
113+
}
51114
});
52115
}
53116

117+
const isValidTransferCode = (code: string) => code.length === 36;
118+
119+
async function redeemTransferCode(
120+
code: string,
121+
transferSessionButton: HTMLButtonElement,
122+
errorElement: HTMLElement | null) {
123+
hideSessionTransferError(errorElement);
124+
transferSessionButton.disabled = true;
125+
126+
try {
127+
const response = await fetch("./tokenTransfer/"+code, {
128+
method: 'GET'
129+
});
130+
const responseData = await safeJson(response);
131+
if (!response.ok) {
132+
throw new Error(responseErrorMessage(responseData));
133+
}
134+
const transferData = responseData as Record<string, unknown>;
135+
if (!responseData || typeof responseData !== 'object' ||
136+
!('ok' in responseData) || transferData.ok !== true) {
137+
throw new Error(sessionTransferErrorFallback());
138+
}
139+
window.location.reload()
140+
} catch (err) {
141+
transferSessionButton.disabled = !isValidTransferCode(code);
142+
showSessionTransferError(
143+
errorElement,
144+
err instanceof Error && err.message ? err.message : sessionTransferErrorFallback());
145+
}
146+
}
54147

55148
const handleSettingsButtonClick = () => {
56149
const settingsButton = document.querySelector('.settings-button')!;
@@ -86,24 +179,22 @@ const handleMenuBarClicked = () => {
86179
});
87180
})
88181

89-
const transferSessionButton = document.getElementById('transferSessionButton')
182+
const transferSessionButton = document.getElementById('transferSessionButton') as HTMLButtonElement | null;
90183
const codeInputField = document.getElementById('codeInput') as HTMLInputElement
91184
if (transferSessionButton) {
92185
transferSessionButton.addEventListener('click', ()=>{
93-
const code = codeInputField.value
94-
fetch("./tokenTransfer/"+code, {
95-
method: 'GET'
96-
})
97-
.then(res => res.json())
98-
.then(()=>{
99-
window.location.reload()
100-
})
186+
const code = codeInputField.value;
187+
redeemTransferCode(
188+
code,
189+
transferSessionButton,
190+
document.getElementById('receive-session-error'));
101191
});
102192
}
103193

104194
if (codeInputField) {
105195
codeInputField.addEventListener('input', (e)=>{
106-
if ((e.target as HTMLInputElement).value?.length === 36) {
196+
hideSessionTransferError(document.getElementById('receive-session-error'));
197+
if (isValidTransferCode((e.target as HTMLInputElement).value)) {
107198
transferSessionButton?.removeAttribute('disabled');
108199
} else {
109200
transferSessionButton?.setAttribute('disabled', 'true');

src/templates/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ <h1>Etherpad</h1>
184184
<h3 data-l10n-id="index.transferSession"></h3>
185185
<div data-l10n-id="index.transferSessionDescription"></div>
186186
<button type="button" class="btn-secondary" style="margin-top: 20px" data-l10n-id="index.transferSessionNow"></button>
187+
<div id="transfer-session-error" role="alert" aria-live="assertive" style="display: none; color: #b00020; margin-top: 10px;"></div>
187188

188189
<!-- Copy link button -->
189190
<div style="display: none" id="copy-link-section">
@@ -202,6 +203,7 @@ <h3 data-l10n-id="index.transferToSystem"></h3>
202203
</div>
203204

204205
<button data-l10n-id="index.transferSessionTitle" id="transferSessionButton" disabled></button>
206+
<div id="receive-session-error" role="alert" aria-live="assertive" style="display: none; color: #b00020; margin-top: 10px;"></div>
205207
</div>
206208

207209
<div>

0 commit comments

Comments
 (0)