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
This commit is contained in:
SamTV12345 2025-11-18 12:23:55 +01:00 committed by GitHub
parent 658ae78922
commit 41cb6803d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 327 additions and 16 deletions

View file

@ -58,6 +58,12 @@
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize"
}
},
{
"name": "transferToken",
"hooks": {
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/tokenTransfer"
}
},
{
"name": "pwa",
"hooks": {

View file

@ -54,6 +54,19 @@
"admin_settings.page-title": "Einstellungen - Etherpad",
"index.newPad": "Neues Pad",
"index.createOpenPad": "Pad öffnen",
"index.settings": "Einstellungen",
"index.receiveSessionTitle": "Sitzung empfangen",
"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.",
"index.code": "Übertragungscode",
"index.transferSessionTitle": "Sitzung übertragen",
"index.transferSession": "1. Sitzung übertragen",
"index.copyLink": "2. Link kopieren",
"index.copyLinkButton": "Übertragungscode kopieren",
"index.copyLinkDescription": "Klicke auf den untenstehenden Button, um den Übertragungscode in deine Zwischenablage zu kopieren.",
"index.transferToSystem": "3. Sitzung einfügen",
"index.transferToSystemDescription": "Öffne den kopierten Link in dem neuen Browser oder Gerät, um deine aktuelle Etherpad-Sitzung zu übertragen.",
"index.transferSessionNow": "Jetzt übertragen",
"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.",
"index.openPad": "Öffne ein vorhandenes Pad mit folgendem Namen:",
"index.recentPads": "Zuletzt bearbeitete Pads",
"index.recentPadsEmpty": "Keine kürzlich bearbeiteten Pads gefunden.",

View file

@ -34,6 +34,18 @@
"admin_settings.page-title": "Settings - Etherpad",
"index.newPad": "New Pad",
"index.settings": "Settings",
"index.transferSessionTitle": "Transfer session",
"index.receiveSessionTitle": "Receive session",
"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.",
"index.transferSession": "1. Transfer session",
"index.transferSessionNow": "Transfer session now",
"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.transferToSystem": "3. Copy session to new system",
"index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.",
"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.",
"index.createOpenPad": "Open pad by name",
"index.openPad": "open an existing Pad with the name:",
"index.recentPads": "Recent Pads",

View file

@ -0,0 +1,45 @@
import {ArgsExpressType} from "../../types/ArgsExpressType";
const db = require('../../db/DB');
import crypto from 'crypto'
type TokenTransferRequest = {
token: string;
prefsHttp: string,
createdAt?: number;
}
const tokenTransferKey = "tokenTransfer:";
export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) => {
app.post('/tokenTransfer', async (req, res) => {
const token = req.body as TokenTransferRequest;
if (!token || !token.token) {
return res.status(400).send({error: 'Invalid request'});
}
const id = crypto.randomUUID()
token.createdAt = Date.now();
await db.set(`${tokenTransferKey}:${id}`, token)
res.send({id});
})
app.get('/tokenTransfer/:token', async (req, res) => {
const id = req.params.token;
if (!id) {
return res.status(400).send({error: 'Invalid request'});
}
const tokenData = await db.get(`${tokenTransferKey}:${id}`);
if (!tokenData) {
return res.status(404).send({error: 'Token not found'});
}
const token = await db.get(`${tokenTransferKey}:${id}`)
res.cookie('token', tokenData.token, {path: '/', maxAge: 1000*60*60*24*365});
res.cookie('prefsHttp', tokenData.prefsHttp, {path: '/', maxAge: 1000*60*60*24*365});
res.send(token);
})
}

116
src/static/js/welcome.ts Normal file
View file

@ -0,0 +1,116 @@
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>';
function getCookie(name: string) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) { // @ts-ignore
return parts.pop().split(';').shift();
}
}
function handleTransferOfSession() {
const transferNowButton = document.querySelector('[data-l10n-id="index.transferSessionNow"]')! as HTMLButtonElement;
transferNowButton.addEventListener('click', async () => {
transferNowButton.style.display = 'inline-flex';
transferNowButton.style.alignItems = 'center';
transferNowButton.style.justifyContent = 'center';
transferNowButton.innerHTML = `${checkmark}`;
transferNowButton.disabled = true;
const responseWithId = await fetch("./tokenTransfer", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
prefsHttp: getCookie('prefsHttp'),
token: getCookie('token'),
})
})
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 handleSettingsButtonClick = () => {
const settingsButton = document.querySelector('.settings-button')!;
const settingsDialog = document.getElementById('settings-dialog') as HTMLDialogElement;
let initialSettingsHtml: string;
settingsDialog.addEventListener('click', (e) => {
if (e.target === settingsDialog) {
settingsDialog.close();
settingsDialog.innerHTML = initialSettingsHtml;
handleMenuBarClicked();
handleTransferOfSession();
}
});
settingsButton.addEventListener('click', () => {
initialSettingsHtml = settingsDialog.innerHTML;
settingsDialog.showModal();
});
};
const handleMenuBarClicked = () => {
const menuBar = document.getElementById('button-bar')!;
menuBar.querySelectorAll('button').forEach((button, index)=>{
button.addEventListener('click', ()=>{
menuBar.querySelectorAll('button').forEach((btn)=>btn.classList.remove('active-btn'));
button.classList.add('active-btn');
const sections: NodeListOf<HTMLDivElement> = document.querySelectorAll('#settings-dialog > div');
sections.forEach((section, index)=>index >= 1 && (section.style.display = 'none'));
(sections[index +1] as HTMLElement).style.display = 'block';
});
})
const transferSessionButton = document.getElementById('transferSessionButton')
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()
})
});
}
if (codeInputField) {
codeInputField.addEventListener('input', (e)=>{
if ((e.target as HTMLInputElement).value?.length === 36) {
transferSessionButton?.removeAttribute('disabled');
} else {
transferSessionButton?.setAttribute('disabled', 'true');
}
})
}
}
window.addEventListener('load', () => {
handleSettingsButtonClick();
handleMenuBarClicked();
handleTransferOfSession();
});

View file

@ -1,3 +1,5 @@
@import url("./src/components/buttons.css");
:root {
--etherpad-color: #64d29b;
--etherpad-color-dark: #4a5d5c;
@ -100,7 +102,7 @@ h1 {
border-radius: 5px;
}
#button, #button:hover, #go2Name [type="submit"] {
#button, #button:hover, #go2Name [type="submit"], #transferSessionButton {
order: 2;
margin-top: 0.5rem;
line-height: 1.25rem;
@ -115,10 +117,14 @@ h1 {
cursor: pointer;
}
#go2Name [type="submit"]:hover {
#go2Name [type="submit"]:hover, #transferSessionButton {
background-color: oklch(52.7% 0.154 150.069)
}
#transferSessionButton:disabled {
opacity: 0.5;
}
#button, #button:hover {
order: 2;
}
@ -132,7 +138,7 @@ h1 {
}
#go2Name [type="submit"] {
#go2Name [type="submit"], #transferSessionButton {
display: block;
background-color: var(--ep-color);
color: white;
@ -234,10 +240,25 @@ a, a:visited, a:hover, a:active {
border-bottom-color: #e5e7eb;
}
#settings-dialog::backdrop {
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
}
.card-content {
padding: 1.5rem;
}
#codeInput {
height: auto;
position: static;
border: 1px solid var(--muted-border);
border-radius: 0.375rem;
font-size: 1rem;
outline: none;
transition: border 0.2s;
}
@media (max-width: 640px) {
#inner {
max-width: 100%;

View file

@ -11,24 +11,25 @@ window.addEventListener('pageshow', (event) => {
});
window.customStart = () => {
document.getElementById('recent-pads').replaceChildren()
const recentPadList = document.getElementById('recent-pads');
if (recentPadList) {
recentPadList.replaceChildren();
}
// define your javascript here
// jquery is available - except index.js
// you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/
const divHoldingPlaceHolderLabel = document
.querySelector('[data-l10n-id="index.placeholderPadEnter"]');
.querySelector('[data-l10n-id="index.placeholderPadEnter"]');
const observer = new MutationObserver(() => {
document.querySelector('#go2Name input')
.setAttribute('placeholder', divHoldingPlaceHolderLabel.textContent);
.setAttribute('placeholder', divHoldingPlaceHolderLabel.textContent);
});
observer
.observe(divHoldingPlaceHolderLabel, {childList: true, subtree: true, characterData: true});
.observe(divHoldingPlaceHolderLabel, {childList: true, subtree: true, characterData: true});
const recentPadList = document.getElementById('recent-pads');
const parentStyle = recentPadList.parentElement.style;
const recentPadListHeading = document.querySelector('[data-l10n-id="index.recentPads"]');
const recentPadsFromLocalStorage = localStorage.getItem('recentPads');
let recentPadListData = [];
@ -38,18 +39,18 @@ window.customStart = () => {
// Remove duplicates based on pad name and sort by timestamp
recentPadListData = recentPadListData.filter(
(pad, index, self) =>
index === self.findIndex((p) => p.name === pad.name)
(pad, index, self) => index === self.findIndex((p) => p.name === pad.name)
).sort((a, b) => new Date(a.timestamp) > new Date(b.timestamp) ? -1 : 1);
if (recentPadListData.length === 0) {
if (recentPadList && recentPadListData.length === 0) {
const parentStyle = recentPadList.parentElement.style;
recentPadListHeading.setAttribute('data-l10n-id', 'index.recentPadsEmpty');
parentStyle.display = 'flex';
parentStyle.justifyContent = 'center';
parentStyle.alignItems = 'center';
parentStyle.maxHeight = '100%';
recentPadList.remove();
} else {
} else if (recentPadList) {
/**
* @typedef {Object} Pad
* @property {string} name

View file

@ -6,7 +6,6 @@ button, .btn
width: auto;
border: none;
font-weight: bold;
text-transform: uppercase;
position: relative;
background: none;
cursor: pointer;
@ -23,3 +22,36 @@ button, .btn
color: #485365;
color: var(--text-color);
}
/* Sekundär (outlined) */
.btn-secondary {
background: transparent;
color: #1f8a3e;
border: 2px solid #1f8a3e;
box-shadow: none;
}
.active-btn {
text-underline-offset: 10px;
text-decoration: underline;
text-decoration-style: solid;
text-decoration-thickness: 2px;
text-decoration-color: #1f8a3e;
cursor: pointer;
}
.btn-secondary:hover {
background: #1f8a3e;
color: #fff;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 1px 2px rgba(0,0,0,0.12);
transform: translateY(-1px);
}
.btn-secondary:disabled {
background: transparent;
color: #aaa;
border-color: #aaa;
box-shadow: none;
cursor: not-allowed;
}

View file

@ -77,7 +77,7 @@
#padname{
max-width:280px;
}
form {
#go2Name {
height: 38px;
background: #fff;
border: 1px solid #bbb;
@ -109,13 +109,32 @@
display: none;
}
@media only screen and (min-device-width: 320px) and (max-device-width: 800px) {
.settings-button {
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
#settings-dialog {
border: none;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
padding: 20px;
}
@media (min-device-width: 320px) and (min-device-width: 800px) {
body {
background: #bbb;
background: -webkit-linear-gradient(#aaa,#eee 60%) center fixed;
background: -moz-linear-gradient(#aaa,#eee 60%) center fixed;
background: -ms-linear-gradient(#aaa,#eee 60%) center fixed;
}
#settings-dialog {
max-width: 50%;
}
#wrapper {
margin-top: 0;
}
@ -136,9 +155,54 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text w-5 h-5 text-white"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path><path d="M10 9H8"></path><path d="M16 13H8"></path><path d="M16 17H8"></path></svg>
</div>
<h1>Etherpad</h1>
<div style="flex-grow: 1"></div>
<button class="settings-button" aria-label="Settings">
<svg width="30px" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="settings-icon">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
</nav>
<!-- Settings menu-->
<dialog id="settings-dialog">
<div id="button-bar">
<button data-l10n-id="index.transferSessionTitle" class="active-btn"></button>
<button data-l10n-id="index.receiveSessionTitle"></button>
</div>
<div>
<!-- Initial link button -->
<h3 data-l10n-id="index.transferSession"></h3>
<div data-l10n-id="index.transferSessionDescription"></div>
<button type="button" class="btn-secondary" style="margin-top: 20px" data-l10n-id="index.transferSessionNow"></button>
<!-- Copy link button -->
<div style="display: none" id="copy-link-section">
<h3 data-l10n-id="index.copyLink"></h3>
<div data-l10n-id="index.copyLinkDescription"></div>
<button type="button" class="btn-secondary" style="margin-top: 20px" data-l10n-id="index.copyLinkButton"></button>
</div>
</div>
<div id="transfer-to-system-section" style="display: none; margin-top: 30px;">
<h3 data-l10n-id="index.transferToSystem"></h3>
<div data-l10n-id="index.transferToSystemDescription"></div>
<p data-l10n-id="index.receiveSessionDescription"></p>
<div>
<label for="codeInput" data-l10n-id="index.code"></label>
<input type="text" id="codeInput"/>
</div>
<button data-l10n-id="index.transferSessionTitle" id="transferSessionButton" disabled></button>
</div>
<div>
</div>
</dialog>
<div class="body">
<div class="mission-statement">
<h2 data-l10n-id="index.createAndShareDocuments"></h2>

View file

@ -3,4 +3,5 @@
window.$ = window.jQuery = require('ep_etherpad-lite/static/js/rjquery').jQuery;
require('ep_etherpad-lite/static/js/l10n')
require('ep_etherpad-lite/static/js/index')
require('ep_etherpad-lite/static/js/welcome')
})()