Skip to content

Commit 41cb680

Browse files
authored
7139 let user maintain a single session across multiple browsers (#7228)
* chore: started with implementation * chore: finished index page * chore: started with double sided modal * chore: continue * chore: completed implementation of transfer token * chore: fixed typescript checks
1 parent 658ae78 commit 41cb680

File tree

10 files changed

+327
-16
lines changed

10 files changed

+327
-16
lines changed

src/ep.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@
5858
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize"
5959
}
6060
},
61+
{
62+
"name": "transferToken",
63+
"hooks": {
64+
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/tokenTransfer"
65+
}
66+
},
6167
{
6268
"name": "pwa",
6369
"hooks": {

src/locales/de.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@
5454
"admin_settings.page-title": "Einstellungen - Etherpad",
5555
"index.newPad": "Neues Pad",
5656
"index.createOpenPad": "Pad öffnen",
57+
"index.settings": "Einstellungen",
58+
"index.receiveSessionTitle": "Sitzung empfangen",
59+
"index.receiveSessionDescription": "Hier kannst du eine Etherpad-Sitzung aus einem anderen Browser oder Gerät empfangen. Bedenke allerdings, dass dadurch deine aktuelle Sitzung, falls vorhanden gelöscht wird.",
60+
"index.code": "Übertragungscode",
61+
"index.transferSessionTitle": "Sitzung übertragen",
62+
"index.transferSession": "1. Sitzung übertragen",
63+
"index.copyLink": "2. Link kopieren",
64+
"index.copyLinkButton": "Übertragungscode kopieren",
65+
"index.copyLinkDescription": "Klicke auf den untenstehenden Button, um den Übertragungscode in deine Zwischenablage zu kopieren.",
66+
"index.transferToSystem": "3. Sitzung einfügen",
67+
"index.transferToSystemDescription": "Öffne den kopierten Link in dem neuen Browser oder Gerät, um deine aktuelle Etherpad-Sitzung zu übertragen.",
68+
"index.transferSessionNow": "Jetzt übertragen",
69+
"index.transferSessionDescription": "Übertrage deine aktuelle Etherpad-Sitzung zu einem anderen Browser oder Gerät, indem du den untenstehenden Button klickst. Dabei wird ein Link in deine Zwischenablage kopiert, den du im neuen Browser oder Gerät öffnen kannst, um deine Sitzung zu übertragen.",
5770
"index.openPad": "Öffne ein vorhandenes Pad mit folgendem Namen:",
5871
"index.recentPads": "Zuletzt bearbeitete Pads",
5972
"index.recentPadsEmpty": "Keine kürzlich bearbeiteten Pads gefunden.",

src/locales/en.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@
3434
"admin_settings.page-title": "Settings - Etherpad",
3535

3636
"index.newPad": "New Pad",
37+
"index.settings": "Settings",
38+
"index.transferSessionTitle": "Transfer session",
39+
"index.receiveSessionTitle": "Receive session",
40+
"index.receiveSessionDescription": "Here you can receive an Etherpad session from another browser or device. Please note, however, that this will delete your current session, if any.",
41+
"index.transferSession": "1. Transfer session",
42+
"index.transferSessionNow": "Transfer session now",
43+
"index.copyLink": "2. Copy link",
44+
"index.copyLinkDescription": "Click on the button below to copy the link to your clipboard.",
45+
"index.copyLinkButton": "Copy link to clipboard",
46+
"index.transferToSystem": "3. Copy session to new system",
47+
"index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.",
48+
"index.transferSessionDescription": "Transfer your current session to browser or device by clicking the button below. This will copy a link to a page that will transfer your session when opened in the target browser or device.",
3749
"index.createOpenPad": "Open pad by name",
3850
"index.openPad": "open an existing Pad with the name:",
3951
"index.recentPads": "Recent Pads",
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import {ArgsExpressType} from "../../types/ArgsExpressType";
2+
const db = require('../../db/DB');
3+
import crypto from 'crypto'
4+
5+
6+
type TokenTransferRequest = {
7+
token: string;
8+
prefsHttp: string,
9+
createdAt?: number;
10+
}
11+
12+
const tokenTransferKey = "tokenTransfer:";
13+
14+
export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) => {
15+
app.post('/tokenTransfer', async (req, res) => {
16+
const token = req.body as TokenTransferRequest;
17+
if (!token || !token.token) {
18+
return res.status(400).send({error: 'Invalid request'});
19+
}
20+
21+
const id = crypto.randomUUID()
22+
token.createdAt = Date.now();
23+
24+
await db.set(`${tokenTransferKey}:${id}`, token)
25+
res.send({id});
26+
})
27+
28+
app.get('/tokenTransfer/:token', async (req, res) => {
29+
const id = req.params.token;
30+
if (!id) {
31+
return res.status(400).send({error: 'Invalid request'});
32+
}
33+
34+
const tokenData = await db.get(`${tokenTransferKey}:${id}`);
35+
if (!tokenData) {
36+
return res.status(404).send({error: 'Token not found'});
37+
}
38+
39+
const token = await db.get(`${tokenTransferKey}:${id}`)
40+
41+
res.cookie('token', tokenData.token, {path: '/', maxAge: 1000*60*60*24*365});
42+
res.cookie('prefsHttp', tokenData.prefsHttp, {path: '/', maxAge: 1000*60*60*24*365});
43+
res.send(token);
44+
})
45+
}

src/static/js/welcome.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
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>';
2+
3+
function getCookie(name: string) {
4+
const value = `; ${document.cookie}`;
5+
const parts = value.split(`; ${name}=`);
6+
if (parts.length === 2) { // @ts-ignore
7+
return parts.pop().split(';').shift();
8+
}
9+
}
10+
11+
12+
function handleTransferOfSession() {
13+
const transferNowButton = document.querySelector('[data-l10n-id="index.transferSessionNow"]')! as HTMLButtonElement;
14+
15+
transferNowButton.addEventListener('click', async () => {
16+
transferNowButton.style.display = 'inline-flex';
17+
transferNowButton.style.alignItems = 'center';
18+
transferNowButton.style.justifyContent = 'center';
19+
transferNowButton.innerHTML = `${checkmark}`;
20+
transferNowButton.disabled = true;
21+
22+
const responseWithId = await fetch("./tokenTransfer", {
23+
method: "POST",
24+
headers: {
25+
"Content-Type": "application/json"
26+
},
27+
body: JSON.stringify({
28+
prefsHttp: getCookie('prefsHttp'),
29+
token: getCookie('token'),
30+
})
31+
})
32+
33+
const copyLinkSection = document.getElementById('copy-link-section')
34+
if (!copyLinkSection) return;
35+
copyLinkSection.style.display = 'block';
36+
37+
const copyButton = document.querySelector('#copy-link-section .btn-secondary') as HTMLButtonElement
38+
const responseData = await responseWithId.json();
39+
copyButton.addEventListener('click', async ()=>{
40+
await navigator.clipboard.writeText(responseData.id);
41+
copyButton.style.display = 'inline-flex';
42+
copyButton.style.alignItems = 'center';
43+
copyButton.style.justifyContent = 'center';
44+
copyButton.innerHTML = `${checkmark}`;
45+
copyButton.disabled = true;
46+
})
47+
});
48+
}
49+
50+
51+
const handleSettingsButtonClick = () => {
52+
const settingsButton = document.querySelector('.settings-button')!;
53+
const settingsDialog = document.getElementById('settings-dialog') as HTMLDialogElement;
54+
let initialSettingsHtml: string;
55+
56+
settingsDialog.addEventListener('click', (e) => {
57+
if (e.target === settingsDialog) {
58+
settingsDialog.close();
59+
settingsDialog.innerHTML = initialSettingsHtml;
60+
handleMenuBarClicked();
61+
handleTransferOfSession();
62+
}
63+
});
64+
65+
settingsButton.addEventListener('click', () => {
66+
initialSettingsHtml = settingsDialog.innerHTML;
67+
settingsDialog.showModal();
68+
});
69+
};
70+
71+
72+
const handleMenuBarClicked = () => {
73+
const menuBar = document.getElementById('button-bar')!;
74+
menuBar.querySelectorAll('button').forEach((button, index)=>{
75+
button.addEventListener('click', ()=>{
76+
menuBar.querySelectorAll('button').forEach((btn)=>btn.classList.remove('active-btn'));
77+
button.classList.add('active-btn');
78+
79+
const sections: NodeListOf<HTMLDivElement> = document.querySelectorAll('#settings-dialog > div');
80+
sections.forEach((section, index)=>index >= 1 && (section.style.display = 'none'));
81+
(sections[index +1] as HTMLElement).style.display = 'block';
82+
});
83+
})
84+
85+
const transferSessionButton = document.getElementById('transferSessionButton')
86+
const codeInputField = document.getElementById('codeInput') as HTMLInputElement
87+
if (transferSessionButton) {
88+
transferSessionButton.addEventListener('click', ()=>{
89+
const code = codeInputField.value
90+
fetch("./tokenTransfer/"+code, {
91+
method: 'GET'
92+
})
93+
.then(res => res.json())
94+
.then(()=>{
95+
window.location.reload()
96+
})
97+
});
98+
}
99+
100+
if (codeInputField) {
101+
codeInputField.addEventListener('input', (e)=>{
102+
if ((e.target as HTMLInputElement).value?.length === 36) {
103+
transferSessionButton?.removeAttribute('disabled');
104+
} else {
105+
transferSessionButton?.setAttribute('disabled', 'true');
106+
}
107+
})
108+
}
109+
110+
}
111+
112+
window.addEventListener('load', () => {
113+
handleSettingsButtonClick();
114+
handleMenuBarClicked();
115+
handleTransferOfSession();
116+
});

src/static/skins/colibris/index.css

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
@import url("./src/components/buttons.css");
2+
13
:root {
24
--etherpad-color: #64d29b;
35
--etherpad-color-dark: #4a5d5c;
@@ -100,7 +102,7 @@ h1 {
100102
border-radius: 5px;
101103
}
102104

103-
#button, #button:hover, #go2Name [type="submit"] {
105+
#button, #button:hover, #go2Name [type="submit"], #transferSessionButton {
104106
order: 2;
105107
margin-top: 0.5rem;
106108
line-height: 1.25rem;
@@ -115,10 +117,14 @@ h1 {
115117
cursor: pointer;
116118
}
117119

118-
#go2Name [type="submit"]:hover {
120+
#go2Name [type="submit"]:hover, #transferSessionButton {
119121
background-color: oklch(52.7% 0.154 150.069)
120122
}
121123

124+
#transferSessionButton:disabled {
125+
opacity: 0.5;
126+
}
127+
122128
#button, #button:hover {
123129
order: 2;
124130
}
@@ -132,7 +138,7 @@ h1 {
132138
}
133139

134140

135-
#go2Name [type="submit"] {
141+
#go2Name [type="submit"], #transferSessionButton {
136142
display: block;
137143
background-color: var(--ep-color);
138144
color: white;
@@ -234,10 +240,25 @@ a, a:visited, a:hover, a:active {
234240
border-bottom-color: #e5e7eb;
235241
}
236242

243+
#settings-dialog::backdrop {
244+
background: rgba(0, 0, 0, 0.45);
245+
backdrop-filter: blur(2px);
246+
}
247+
237248
.card-content {
238249
padding: 1.5rem;
239250
}
240251

252+
#codeInput {
253+
height: auto;
254+
position: static;
255+
border: 1px solid var(--muted-border);
256+
border-radius: 0.375rem;
257+
font-size: 1rem;
258+
outline: none;
259+
transition: border 0.2s;
260+
}
261+
241262
@media (max-width: 640px) {
242263
#inner {
243264
max-width: 100%;

src/static/skins/colibris/index.js

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,25 @@ window.addEventListener('pageshow', (event) => {
1111
});
1212

1313
window.customStart = () => {
14-
document.getElementById('recent-pads').replaceChildren()
14+
const recentPadList = document.getElementById('recent-pads');
15+
if (recentPadList) {
16+
recentPadList.replaceChildren();
17+
}
1518
// define your javascript here
1619
// jquery is available - except index.js
1720
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
1821
const divHoldingPlaceHolderLabel = document
19-
.querySelector('[data-l10n-id="index.placeholderPadEnter"]');
22+
.querySelector('[data-l10n-id="index.placeholderPadEnter"]');
2023

2124
const observer = new MutationObserver(() => {
2225
document.querySelector('#go2Name input')
23-
.setAttribute('placeholder', divHoldingPlaceHolderLabel.textContent);
26+
.setAttribute('placeholder', divHoldingPlaceHolderLabel.textContent);
2427
});
2528

2629
observer
27-
.observe(divHoldingPlaceHolderLabel, {childList: true, subtree: true, characterData: true});
30+
.observe(divHoldingPlaceHolderLabel, {childList: true, subtree: true, characterData: true});
2831

2932

30-
const recentPadList = document.getElementById('recent-pads');
31-
const parentStyle = recentPadList.parentElement.style;
3233
const recentPadListHeading = document.querySelector('[data-l10n-id="index.recentPads"]');
3334
const recentPadsFromLocalStorage = localStorage.getItem('recentPads');
3435
let recentPadListData = [];
@@ -38,18 +39,18 @@ window.customStart = () => {
3839

3940
// Remove duplicates based on pad name and sort by timestamp
4041
recentPadListData = recentPadListData.filter(
41-
(pad, index, self) =>
42-
index === self.findIndex((p) => p.name === pad.name)
42+
(pad, index, self) => index === self.findIndex((p) => p.name === pad.name)
4343
).sort((a, b) => new Date(a.timestamp) > new Date(b.timestamp) ? -1 : 1);
4444

45-
if (recentPadListData.length === 0) {
45+
if (recentPadList && recentPadListData.length === 0) {
46+
const parentStyle = recentPadList.parentElement.style;
4647
recentPadListHeading.setAttribute('data-l10n-id', 'index.recentPadsEmpty');
4748
parentStyle.display = 'flex';
4849
parentStyle.justifyContent = 'center';
4950
parentStyle.alignItems = 'center';
5051
parentStyle.maxHeight = '100%';
5152
recentPadList.remove();
52-
} else {
53+
} else if (recentPadList) {
5354
/**
5455
* @typedef {Object} Pad
5556
* @property {string} name

src/static/skins/colibris/src/components/buttons.css

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ button, .btn
66
width: auto;
77
border: none;
88
font-weight: bold;
9-
text-transform: uppercase;
109
position: relative;
1110
background: none;
1211
cursor: pointer;
@@ -23,3 +22,36 @@ button, .btn
2322
color: #485365;
2423
color: var(--text-color);
2524
}
25+
26+
/* Sekundär (outlined) */
27+
.btn-secondary {
28+
background: transparent;
29+
color: #1f8a3e;
30+
border: 2px solid #1f8a3e;
31+
box-shadow: none;
32+
}
33+
34+
.active-btn {
35+
text-underline-offset: 10px;
36+
text-decoration: underline;
37+
text-decoration-style: solid;
38+
text-decoration-thickness: 2px;
39+
text-decoration-color: #1f8a3e;
40+
cursor: pointer;
41+
}
42+
43+
44+
.btn-secondary:hover {
45+
background: #1f8a3e;
46+
color: #fff;
47+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.12);
48+
transform: translateY(-1px);
49+
}
50+
51+
.btn-secondary:disabled {
52+
background: transparent;
53+
color: #aaa;
54+
border-color: #aaa;
55+
box-shadow: none;
56+
cursor: not-allowed;
57+
}

0 commit comments

Comments
 (0)