diff --git a/preload.js b/preload.js index 8dff4f5..da696f2 100644 --- a/preload.js +++ b/preload.js @@ -83,7 +83,6 @@ contextBridge.exposeInMainWorld('api', { createManagedOnlineBackup: () => ipcRenderer.invoke('online-backup:create-managed'), copyManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:copy-managed', id), deleteManagedOnlineBackup: (id) => ipcRenderer.invoke('online-backup:delete-managed', id), - createOnlineBackup: () => ipcRenderer.invoke('online-backup:create-managed'), restoreOnlineBackup: (key) => ipcRenderer.invoke('online-backup:restore', key), // Folder Monitor diff --git a/renderer/app.js b/renderer/app.js index 856c55f..69e39a2 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -41,6 +41,7 @@ function refreshLocalizedRuntimeUi() { const activeRecentTab = document.querySelector('.recent-tab.active'); const hint = document.getElementById('recentFilesHint'); if (hint && activeRecentTab) hint.textContent = localizeUiText(activeRecentTab.dataset.panel === 'statsTab' ? 'Upload-Statistiken' : 'Zuletzt erzeugte Upload-Links'); + renderManagedOnlineBackups(); } // Dropdown options for "Add Account" modal: value -> label @@ -61,6 +62,9 @@ let config = { hosters: {}, hosterSettings: {}, globalSettings: {} }; let hosterSettings = {}; let uploading = false; let healthCheckRunning = false; +let managedOnlineBackups = []; +let managedOnlineBackupLoadGeneration = 0; +const managedOnlineBackupPendingActions = new Map(); let _rLongTasks = 0, _rLongTaskMax = 0, _rFrameLast = 0, _rFrameWorst = 0, _rFrameCount = 0, _rFrameJank = 0, _rPerfLastLog = 0, _rPerfWindowStart = 0; function _rElLabel(el) { @@ -2637,15 +2641,140 @@ function showImportQueuePersistenceError(error) { function setOnlineBackupStatus(message, state = '') { const status = document.getElementById('onlineBackupStatus'); if (!status) return; - status.textContent = message; + status.textContent = String(message || '').replace(/MHU2-[A-Za-z0-9_-]{70}/gu, localizeUiText('Geschützter Schlüssel')); status.dataset.state = state; } -function setOnlineBackupBusy(busy) { - const createButton = document.getElementById('createOnlineBackupBtn'); +function syncOnlineBackupRestoreButton(busy = false) { const restoreButton = document.getElementById('restoreOnlineBackupBtn'); - if (createButton) createButton.disabled = busy; - if (restoreButton) restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || ''); + if (!restoreButton) return; + restoreButton.dataset.busy = busy ? 'true' : 'false'; + restoreButton.disabled = busy || !/^MHU2-[A-Za-z0-9_-]{70}$/.test(document.getElementById('onlineBackupKeyInput')?.value.trim() || ''); +} + +function normalizeManagedOnlineBackups(entries) { + const sanitized = new Map(); + for (const entry of Array.isArray(entries) ? entries : []) { + if (!entry || typeof entry !== 'object') continue; + if (typeof entry.id !== 'string' || !/^[A-Za-z0-9_-]{22}$/.test(entry.id)) continue; + if (typeof entry.displayKey !== 'string' || !entry.displayKey || /MHU2-[A-Za-z0-9_-]{70}/.test(entry.displayKey)) continue; + const createdAt = new Date(entry.createdAt); + if (Number.isNaN(createdAt.getTime())) continue; + sanitized.set(entry.id, { id: entry.id, displayKey: entry.displayKey, createdAt: createdAt.toISOString() }); + } + return [...sanitized.values()].sort((left, right) => right.createdAt.localeCompare(left.createdAt)); +} + +function replaceManagedOnlineBackups(entries) { + managedOnlineBackups = normalizeManagedOnlineBackups(entries); + renderManagedOnlineBackups(); +} + +function renderManagedOnlineBackups() { + const list = document.getElementById('managedOnlineBackupList'); + if (!list) return; + const content = document.createDocumentFragment(); + if (managedOnlineBackups.length === 0) { + const empty = document.createElement('p'); + empty.className = 'online-backup-managed-empty'; + empty.textContent = localizeUiText('Noch keine Schlüssel auf diesem Gerät erstellt.'); + content.append(empty); + } else { + for (const entry of managedOnlineBackups) { + const row = document.createElement('article'); + row.className = 'online-backup-managed-row'; + const key = document.createElement('span'); + key.className = 'online-backup-managed-key'; + key.textContent = entry.displayKey; + const created = document.createElement('span'); + created.className = 'online-backup-managed-created'; + created.textContent = formatDateTime(entry.createdAt).text; + const actions = document.createElement('div'); + actions.className = 'online-backup-managed-actions'; + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.className = 'btn btn-secondary online-backup-copy-btn'; + copyButton.textContent = localizeUiText('Schlüssel kopieren'); + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'btn btn-danger online-backup-delete-btn'; + deleteButton.textContent = localizeUiText('Online-Backup löschen'); + const pendingAction = managedOnlineBackupPendingActions.get(entry.id); + copyButton.disabled = pendingAction === 'copy' || pendingAction === 'delete'; + deleteButton.disabled = pendingAction === 'delete'; + copyButton.addEventListener('click', () => copyManagedOnlineBackup(entry, copyButton)); + deleteButton.addEventListener('click', () => deleteManagedOnlineBackup(entry, row)); + actions.append(copyButton, deleteButton); + row.append(key, created, actions); + content.append(row); + } + } + list.replaceChildren(content); +} + +async function loadManagedOnlineBackups() { + const generation = ++managedOnlineBackupLoadGeneration; + try { + const result = await window.api.listManagedOnlineBackups(); + if (generation !== managedOnlineBackupLoadGeneration) return false; + if (!result?.ok || !Array.isArray(result.entries)) { + setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error'); + return false; + } + replaceManagedOnlineBackups(result.entries); + return true; + } catch { + if (generation === managedOnlineBackupLoadGeneration) setOnlineBackupStatus('Online-Sicherungen konnten nicht geladen werden', 'error'); + return false; + } +} + +async function copyManagedOnlineBackup(entry, button) { + if (button.disabled || managedOnlineBackupPendingActions.has(entry.id)) return; + managedOnlineBackupPendingActions.set(entry.id, 'copy'); + button.disabled = true; + try { + const result = await window.api.copyManagedOnlineBackup(entry.id); + if (!result?.ok) { + setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error'); + return; + } + showCopyToast('Online-Schlüssel kopiert'); + } catch { + setOnlineBackupStatus('Online-Sicherung konnte nicht kopiert werden', 'error'); + } finally { + managedOnlineBackupPendingActions.delete(entry.id); + renderManagedOnlineBackups(); + } +} + +async function deleteManagedOnlineBackup(entry, row) { + const confirmed = await showAppConfirm({ + title: 'Online-Backup löschen', + message: 'Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', + confirmText: 'Löschen', + danger: true + }); + if (!confirmed || !row.isConnected) return; + if (managedOnlineBackupPendingActions.has(entry.id)) return; + managedOnlineBackupPendingActions.set(entry.id, 'delete'); + const controls = [...row.querySelectorAll('button')]; + controls.forEach(button => { button.disabled = true; }); + try { + const result = await window.api.deleteManagedOnlineBackup(entry.id); + if (!result?.ok || result.removedId !== entry.id) { + setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error'); + return; + } + managedOnlineBackupLoadGeneration++; + replaceManagedOnlineBackups(managedOnlineBackups.filter(item => item.id !== result.removedId)); + setOnlineBackupStatus('Schlüssel gelöscht', 'success'); + } catch { + setOnlineBackupStatus('Online-Sicherung konnte nicht gelöscht werden', 'error'); + } finally { + managedOnlineBackupPendingActions.delete(entry.id); + renderManagedOnlineBackups(); + } } async function doOnlineBackupCreate() { @@ -2654,27 +2783,30 @@ async function doOnlineBackupCreate() { try { await flushPendingSettingsSaves(); openOnlineBackupView(); - } catch (error) { + await loadManagedOnlineBackups(); + } catch { openOnlineBackupView(); - setOnlineBackupStatus(error.message || String(error), 'error'); + setOnlineBackupStatus('Nicht alle Einstellungen konnten gespeichert werden', 'error'); doOnlineBackupCreate.busy = false; return; } - setOnlineBackupBusy(true); + const createButton = document.getElementById('createOnlineBackupBtn'); + if (createButton) createButton.disabled = true; setOnlineBackupStatus('Verschlüssele und speichere Einstellungen…', 'busy'); try { - const result = await window.api.createOnlineBackup(); - if (!result || !result.ok) throw new Error(result?.error || 'Online-Sicherung konnte nicht erstellt werden'); - const output = document.getElementById('onlineBackupKeyOutput'); - const copyButton = document.getElementById('copyOnlineBackupKeyBtn'); - if (output) output.value = result.key; - if (copyButton) copyButton.disabled = false; + const result = await window.api.createManagedOnlineBackup(); + if (!result?.ok || normalizeManagedOnlineBackups([result.entry]).length !== 1) { + setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error'); + return; + } + managedOnlineBackupLoadGeneration++; + replaceManagedOnlineBackups([...managedOnlineBackups, result.entry]); setOnlineBackupStatus('Neuer Schlüssel erstellt. Ältere Schlüssel bleiben gültig.', 'success'); showCopyToast('Online-Schlüssel erstellt'); - } catch (error) { - setOnlineBackupStatus(error.message || String(error), 'error'); + } catch { + setOnlineBackupStatus('Online-Sicherung konnte nicht erstellt werden', 'error'); } finally { - setOnlineBackupBusy(false); + if (createButton?.isConnected) createButton.disabled = false; doOnlineBackupCreate.busy = false; } } @@ -2702,7 +2834,7 @@ async function doOnlineBackupRestore() { input?.focus(); return; } - setOnlineBackupBusy(true); + syncOnlineBackupRestoreButton(true); setOnlineBackupStatus('Speichere aktuelle Einstellungen…', 'busy'); try { await flushPendingSettingsSaves(); @@ -2722,10 +2854,10 @@ async function doOnlineBackupRestore() { if (warnings.length) setOnlineBackupStatus(`Einstellungen übernommen. Bitte prüfen: ${warnings.join(', ')}.`, 'warning'); else setOnlineBackupStatus('Alle Accounts und Einstellungen wurden übernommen.', 'success'); if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError); - } catch (error) { - setOnlineBackupStatus(error.message || String(error), 'error'); + } catch { + setOnlineBackupStatus('Online-Sicherung konnte nicht importiert werden', 'error'); } finally { - setOnlineBackupBusy(false); + syncOnlineBackupRestoreButton(false); } } @@ -4867,22 +4999,20 @@ function renderSettings() {

Verschlüsseltes Online-Backup

Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.

-
- - Jeder Export erzeugt einen neuen Schlüssel. Ältere Schlüssel bleiben gültig. -
-
- - - -
- +

Behandle den Schlüssel wie ein Passwort. Wer ihn besitzt, kann die verschlüsselten Einstellungen entschlüsseln.

+
+

Auf diesem Gerät erstellt

+
+
+
Lokales Datei-Backup
@@ -4918,6 +5048,7 @@ function renderSettings() { _syncSidebarIndicator(activeButton); if (focus) activeButton.focus(); content.scrollTop = 0; + if (target === 'backup') loadManagedOnlineBackups(); }; navigation.addEventListener('click', (event) => { @@ -5189,15 +5320,10 @@ function renderSettings() { if (event.target.checked) document.getElementById('localBackupPasswordInput')?.focus(); }); document.getElementById('createOnlineBackupBtn').addEventListener('click', () => doOnlineBackupCreate()); - document.getElementById('copyOnlineBackupKeyBtn').addEventListener('click', async () => { - const key = document.getElementById('onlineBackupKeyOutput').value; - if (!key) return; - await window.api.copyToClipboard(key); - showCopyToast('Online-Schlüssel kopiert'); - }); + renderManagedOnlineBackups(); document.getElementById('onlineBackupKeyInput').addEventListener('input', (event) => { const valid = /^MHU2-[A-Za-z0-9_-]{70}$/.test(event.target.value.trim()); - document.getElementById('restoreOnlineBackupBtn').disabled = !valid; + document.getElementById('restoreOnlineBackupBtn').disabled = document.getElementById('restoreOnlineBackupBtn').dataset.busy === 'true' || !valid; if (event.target.value && !valid) setOnlineBackupStatus('Der Schlüssel muss exakt 75 Zeichen lang sein.', ''); else setOnlineBackupStatus('', ''); }); diff --git a/renderer/i18n.js b/renderer/i18n.js index efb4771..c056209 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -307,6 +307,12 @@ ['Verschlüsseltes Online-Backup', 'Encrypted online backup'], ['Die Verschlüsselung findet ausschließlich auf diesem Gerät statt. Der Server speichert nur verschlüsselte Daten.', 'Encryption takes place only on this device. The server stores encrypted data only.'], ['Neuen Schlüssel erzeugen', 'Generate new key'], + ['Auf diesem Gerät erstellt', 'Created on this device'], + ['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'], + ['Schlüssel kopieren', 'Copy key'], + ['Online-Backup löschen', 'Delete online backup'], + ['Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', 'This encrypted online backup will be permanently deleted from the server.'], + ['Schlüssel gelöscht', 'Key deleted'], ['Jeder Export erzeugt einen neuen Schlüssel. Ältere Schlüssel bleiben gültig.', 'Each export creates a new key. Older keys remain valid.'], ['Dein neuer Schlüssel', 'Your new key'], ['Neuer Schlüssel', 'Your new key'], @@ -458,6 +464,8 @@ ['Online-Schlüssel erstellt', 'Online key created'], ['Online-Schlüssel kopiert', 'Online key copied'], ['Online-Sicherung konnte nicht erstellt werden', 'Online backup could not be created'], + ['Online-Sicherungen konnten nicht geladen werden', 'Online backups could not be loaded'], + ['Online-Sicherung konnte nicht kopiert werden', 'Online backup could not be copied'], ['Online-Sicherung konnte nicht importiert werden', 'Online backup could not be imported'], ['OTP wird geprüft…', 'Checking OTP…'], ['OTP-Prüfung fehlgeschlagen', 'OTP check failed'], @@ -530,6 +538,12 @@ ['Online-Sicherung konnte nicht geladen werden', 'Online backup could not be loaded'], ['Online-Sicherungsdienst hat ungültige Daten geliefert', 'The online backup service returned invalid data'], ['Online-Sicherung konnte nicht gelöscht werden', 'Online backup could not be deleted'], + ['Online-Sicherungsanfrage wurde abgewiesen', 'Online backup request was rejected'], + ['Online-Sicherungs-ID ist ungültig', 'Online backup ID is invalid'], + ['Ungültiger Erstellungszeitpunkt', 'Invalid creation timestamp'], + ['Gespeicherter Online-Schlüsselbund ist ungültig', 'Stored online keyring is invalid'], + ['Online-Sicherungsschlüssel konnte nicht sicher vorbereitet werden', 'Online backup key could not be prepared securely'], + ['Geschützter Schlüssel', 'Protected key'], ['Backup hat eine ungültige Struktur', 'The backup has an invalid structure'], ['Einstellungen werden bereits importiert', 'Settings are already being imported'], ['Während laufender Uploads können keine Einstellungen importiert werden', 'Settings cannot be imported while uploads are running'], diff --git a/renderer/styles.css b/renderer/styles.css index 62a540f..1e72423 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -1868,6 +1868,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } .online-backup-panel { display: grid; gap: 16px; + min-width: 0; padding: 18px; margin-bottom: 18px; border: 1px solid var(--border); @@ -1886,7 +1887,6 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } line-height: 1.5; } -.online-backup-action, .backup-file-actions { display: flex; align-items: center; @@ -1920,6 +1920,75 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } color: #fbbf24; } +.online-backup-managed { + display: grid; + gap: 10px; + min-width: 0; +} + +.online-backup-managed h4 { + margin: 0; + color: var(--text); + font-size: 14px; +} + +.online-backup-managed-list { + display: grid; + gap: 8px; + min-width: 0; +} + +.online-backup-managed-row { + display: grid; + grid-template-columns: 168px 188px minmax(0, 1fr); + gap: 10px; + align-items: center; + min-width: 0; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-card); +} + +.online-backup-managed-key { + min-width: 0; + overflow-wrap: anywhere; + color: var(--text); + font-family: Consolas, monospace; + font-size: 13px; +} + +.online-backup-managed-created { + min-width: 0; + color: var(--text-dim); + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.online-backup-managed-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + min-width: 0; +} + +.online-backup-managed-actions .btn { + white-space: nowrap; +} + +.online-backup-managed-empty { + padding: 12px; + border: 1px dashed var(--border); + border-radius: 8px; + text-align: center; +} + +.online-backup-footer { + display: flex; + justify-content: flex-end; + min-width: 0; +} + .online-backup-status { min-height: 18px; color: var(--text-dim); @@ -3607,9 +3676,24 @@ input[type="checkbox"] { } @media (max-width: 820px) { - .online-backup-key-row { + .online-backup-key-row, + .online-backup-managed-row { grid-template-columns: 1fr; } + + .online-backup-managed-actions { + width: 100%; + } + + .online-backup-managed-actions .btn { + flex: 1 1 0; + min-width: 0; + white-space: normal; + } + + .online-backup-footer .btn { + width: 100%; + } } #settings-view { diff --git a/tests/i18n.test.js b/tests/i18n.test.js index 47b79bb..7bc7ff6 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -23,6 +23,23 @@ test('translates compact online backup labels in both directions', () => { assert.equal(translateText('Import existing key', 'de'), 'Schlüssel importieren'); }); +test('translates managed online backup controls in both directions', () => { + const pairs = [ + ['Auf diesem Gerät erstellt', 'Created on this device'], + ['Noch keine Schlüssel auf diesem Gerät erstellt.', 'No keys have been created on this device yet.'], + ['Schlüssel kopieren', 'Copy key'], + ['Online-Backup löschen', 'Delete online backup'], + ['Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', 'This encrypted online backup will be permanently deleted from the server.'], + ['Schlüssel gelöscht', 'Key deleted'], + ['Importieren', 'Import'] + ]; + + for (const [german, english] of pairs) { + assert.equal(translateText(german, 'en'), english); + assert.equal(translateText(english, 'de'), german); + } +}); + test('translates the account check timestamp label', () => { assert.equal(translateText('geprüft', 'en'), 'checked'); assert.equal(translateText('checked', 'de'), 'geprüft'); diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index b4e0e9d..64dfb71 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -24,7 +24,12 @@ test('exposes managed online backup operations through narrow IPC boundaries', ( assert.match(preloadSource, /createManagedOnlineBackup:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:create-managed'\)/u); assert.match(preloadSource, /copyManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:copy-managed',\s*id\)/u); assert.match(preloadSource, /deleteManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:delete-managed',\s*id\)/u); - assert.match(preloadSource, /createOnlineBackup:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:create-managed'\)/u); + assert.match(preloadSource, /restoreOnlineBackup:\s*\(key\)\s*=>\s*ipcRenderer\.invoke\('online-backup:restore',\s*key\)/u); + assert.deepEqual( + [...preloadSource.matchAll(/^\s{2}(\w*OnlineBackups?):/gmu)].map((match) => match[1]), + ['listManagedOnlineBackups', 'createManagedOnlineBackup', 'copyManagedOnlineBackup', 'deleteManagedOnlineBackup', 'restoreOnlineBackup'] + ); + assert.doesNotMatch(preloadSource, /^\s{2}createOnlineBackup:/mu); assert.doesNotMatch(preloadSource, /ipcRenderer\.invoke\('online-backup:create'\)/u); assert.match(mainSource, /path\.join\(app\.getPath\('userData'\),\s*'online-backup-keys\.json'\)/u); assert.match(mainSource, /Buffer\.from\(id,\s*'base64url'\)/u); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index d468e1e..cc1b662 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -62,23 +62,116 @@ test('Windows compositor paints the full hidden surface with an RDP session envi const userDataPath = path.join(probeRoot, 'user-data'); fs.writeFileSync(preloadPath, ` const { contextBridge } = require('electron'); +const managedOnlineBackupProbeCalls = []; +let pendingManagedOnlineBackupDelete = null; contextBridge.exposeInMainWorld('api', { onUpdateAvailable() {}, onUpdateProgress() {}, onPrepareClose() {}, - getConfig() { return new Promise(() => {}); } + getConfig() { return new Promise(() => {}); }, + listManagedOnlineBackups() { + managedOnlineBackupProbeCalls.push(['list']); + return Promise.resolve({ ok: true, entries: [ + { id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }, + { id: 'BBBBBBBBBBBBBBBBBBBBBB', displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z' } + ] }); + }, + createManagedOnlineBackup() { + managedOnlineBackupProbeCalls.push(['create']); + return Promise.resolve({ ok: true, entry: { id: 'CCCCCCCCCCCCCCCCCCCCCC', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' } }); + }, + copyManagedOnlineBackup(id) { + managedOnlineBackupProbeCalls.push(['copy', id]); + return Promise.resolve({ ok: true }); + }, + deleteManagedOnlineBackup(id) { + managedOnlineBackupProbeCalls.push(['delete', id]); + return new Promise(resolve => { pendingManagedOnlineBackupDelete = { id, resolve }; }); + }, + releaseManagedOnlineBackupDelete() { + const pending = pendingManagedOnlineBackupDelete; + pendingManagedOnlineBackupDelete = null; + pending.resolve({ ok: true, removedId: pending.id, notFound: false }); + }, + getManagedOnlineBackupProbeCalls() { return managedOnlineBackupProbeCalls; } }); `, 'utf8'); + const onlineBackupBehaviorScript = `(async () => { + const fixture = document.createElement('section'); + fixture.innerHTML = '
'; + document.body.append(fixture); + let confirmation = null; + showAppConfirm = async options => { confirmation = options; return true; }; + flushPendingSettingsSaves = async () => {}; + openOnlineBackupView = () => {}; + await loadManagedOnlineBackups(); + const initialKeys = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent); + const maliciousEntry = { id: 'DDDDDDDDDDDDDDDDDDDDDD', displayKey: 'prefix MHU2-' + 'X'.repeat(70) + ' suffix', createdAt: '2026-08-24T12:00:00.000Z' }; + replaceManagedOnlineBackups([...managedOnlineBackups, maliciousEntry]); + const secretRejected = !document.body.textContent.includes(maliciousEntry.displayKey) && managedOnlineBackups.every(entry => entry.displayKey !== maliciousEntry.displayKey); + document.querySelector('.online-backup-copy-btn').click(); + await new Promise(resolve => setTimeout(resolve, 0)); + document.querySelector('.online-backup-delete-btn').click(); + await new Promise(resolve => setTimeout(resolve, 0)); + renderManagedOnlineBackups(); + const pendingDeleteControlsDisabled = [...document.querySelector('.online-backup-managed-row').querySelectorAll('button')].every(button => button.disabled); + window.api.releaseManagedOnlineBackupDelete(); + await new Promise(resolve => setTimeout(resolve, 0)); + const afterDelete = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent); + await doOnlineBackupCreate(); + const afterCreate = [...document.querySelectorAll('.online-backup-managed-key')].map(element => element.textContent); + return { + initialKeys, + secretRejected, + pendingDeleteControlsDisabled, + afterDelete, + afterCreate, + confirmation, + calls: await window.api.getManagedOnlineBackupProbeCalls(), + stateFields: Object.keys(managedOnlineBackups[0] || {}).sort(), + secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) + }; + })()`; const onlineBackupLayoutScript = `(() => { - document.documentElement.lang = 'de'; - document.body.innerHTML = '
'; - const rows = [...document.querySelectorAll('.online-backup-key-row')]; - return rows.map(row => { - const label = row.querySelector('label').getBoundingClientRect(); - const input = row.querySelector('input').getBoundingClientRect(); - const button = row.querySelector('button').getBoundingClientRect(); - return { labelWidth: label.width, inputLeft: input.left, buttonRight: button.right }; - }); + const measure = language => { + const copy = language === 'de' ? 'Schlüssel kopieren' : 'Copy key'; + const remove = language === 'de' ? 'Online-Backup löschen' : 'Delete online backup'; + document.documentElement.lang = language; + document.body.innerHTML = '

Managed

ABCDEFGH…123422.08.2026 12:00
ZYXWVUTS…987621.08.2026 11:00
'; + const panel = document.querySelector('.online-backup-panel'); + const panelRect = panel.getBoundingClientRect(); + const panelStyle = getComputedStyle(panel); + const rows = [...document.querySelectorAll('.online-backup-managed-row')].map(row => { + const key = row.querySelector('.online-backup-managed-key').getBoundingClientRect(); + const created = row.querySelector('.online-backup-managed-created').getBoundingClientRect(); + const actions = row.querySelector('.online-backup-managed-actions').getBoundingClientRect(); + return { keyLeft: key.left, createdLeft: created.left, actionsRight: actions.right }; + }); + return { + rows, + contentRight: panelRect.right - parseFloat(panelStyle.paddingRight), + createRight: document.querySelector('.online-backup-footer button').getBoundingClientRect().right + }; + }; + return { german: measure('de'), english: measure('en') }; + })()`; + const onlineBackupNarrowLayoutScript = `(() => { + document.body.innerHTML = '
ABCDEFGH…123422/08/2026, 12:00
'; + const row = document.querySelector('.online-backup-managed-row').getBoundingClientRect(); + const key = document.querySelector('.online-backup-managed-key').getBoundingClientRect(); + const created = document.querySelector('.online-backup-managed-created').getBoundingClientRect(); + const actions = document.querySelector('.online-backup-managed-actions').getBoundingClientRect(); + const rowStyle = getComputedStyle(document.querySelector('.online-backup-managed-row')); + const rowContentWidth = row.width - parseFloat(rowStyle.paddingLeft) - parseFloat(rowStyle.paddingRight) - parseFloat(rowStyle.borderLeftWidth) - parseFloat(rowStyle.borderRightWidth); + const footer = document.querySelector('.online-backup-footer').getBoundingClientRect(); + const create = document.querySelector('.online-backup-footer button').getBoundingClientRect(); + return { + horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + rowOverflow: document.querySelector('.online-backup-managed-row').scrollWidth > document.querySelector('.online-backup-managed-row').clientWidth + 1, + stacked: key.top < created.top && created.top < actions.top, + actionsStretched: Math.abs(actions.width - rowContentWidth) <= 1, + createStretched: Math.abs(create.width - footer.width) <= 1 + }; })()`; const probeSource = ` const { app, BrowserWindow, screen } = require('electron'); @@ -115,7 +208,11 @@ app.whenReady().then(async () => { const middleY = Math.floor(size.height / 2); await window.loadFile(process.env.MHU_RENDERER_PATH, { query: { language: 'en', version: '2.1.31' } }); const liveSpeedChart = await window.webContents.executeJavaScript('({ baselinePresent: Boolean(document.querySelector(".upload-speed-baseline")), canvasWidth: document.getElementById("uploadSpeedCanvas")?.getBoundingClientRect().width || 0 })'); + const onlineBackupBehavior = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupBehaviorScript)}); const onlineBackupLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupLayoutScript)}); + window.setContentSize(760, Math.min(900, display.workAreaSize.height)); + await new Promise(resolve => setTimeout(resolve, 50)); + const onlineBackupNarrowLayout = await window.webContents.executeJavaScript(${JSON.stringify(onlineBackupNarrowLayoutScript)}); fs.writeFileSync(outputPath, JSON.stringify({ size, dom, @@ -127,7 +224,9 @@ app.whenReady().then(async () => { leftEdge: pixelAt(bitmap, size.width, 0, middleY), rightEdge: pixelAt(bitmap, size.width, size.width - 1, middleY), liveSpeedChart, - onlineBackupLayout + onlineBackupBehavior, + onlineBackupLayout, + onlineBackupNarrowLayout }), 'utf8'); window.destroy(); app.exit(0); @@ -167,10 +266,43 @@ app.whenReady().then(async () => { assert.ok(result.rightEdge[0] > result.rightEdge[1] && result.rightEdge[2] > result.rightEdge[1]); assert.ok(result.liveSpeedChart.canvasWidth > 0); assert.equal(result.liveSpeedChart.baselinePresent, false); - assert.equal(result.onlineBackupLayout.length, 2); - assert.ok(Math.abs(result.onlineBackupLayout[0].labelWidth - result.onlineBackupLayout[1].labelWidth) <= 1); - assert.ok(Math.abs(result.onlineBackupLayout[0].inputLeft - result.onlineBackupLayout[1].inputLeft) <= 1); - assert.ok(Math.abs(result.onlineBackupLayout[0].buttonRight - result.onlineBackupLayout[1].buttonRight) <= 1); + assert.deepEqual(result.onlineBackupBehavior.initialKeys, ['MHU2-ZYXW…9876', 'MHU2-ABCD…1234']); + assert.equal(result.onlineBackupBehavior.secretRejected, true); + assert.equal(result.onlineBackupBehavior.pendingDeleteControlsDisabled, true); + assert.deepEqual(result.onlineBackupBehavior.afterDelete, ['MHU2-ABCD…1234']); + assert.equal(result.onlineBackupBehavior.afterCreate[0], 'MHU2-QWER…4321'); + assert.equal(result.onlineBackupBehavior.afterCreate.length, 3); + assert.deepEqual(result.onlineBackupBehavior.confirmation, { + title: 'Online-Backup löschen', + message: 'Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.', + confirmText: 'Löschen', + danger: true + }); + assert.deepEqual(result.onlineBackupBehavior.calls, [ + ['list'], + ['copy', 'BBBBBBBBBBBBBBBBBBBBBB'], + ['delete', 'BBBBBBBBBBBBBBBBBBBBBB'], + ['list'], + ['create'] + ]); + assert.deepEqual(result.onlineBackupBehavior.stateFields, ['createdAt', 'displayKey', 'id']); + assert.equal(result.onlineBackupBehavior.secretInBody, false); + assert.ok(Math.abs(result.onlineBackupLayout.german.createRight - result.onlineBackupLayout.german.contentRight) <= 1); + assert.ok(Math.abs(result.onlineBackupLayout.english.createRight - result.onlineBackupLayout.english.contentRight) <= 1); + for (const layout of [result.onlineBackupLayout.german, result.onlineBackupLayout.english]) { + assert.equal(layout.rows.length, 2); + assert.ok(Math.abs(layout.rows[0].keyLeft - layout.rows[1].keyLeft) <= 1); + assert.ok(Math.abs(layout.rows[0].createdLeft - layout.rows[1].createdLeft) <= 1); + assert.ok(Math.abs(layout.rows[0].actionsRight - layout.rows[1].actionsRight) <= 1); + } + assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].keyLeft - result.onlineBackupLayout.english.rows[0].keyLeft) <= 1); + assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].createdLeft - result.onlineBackupLayout.english.rows[0].createdLeft) <= 1); + assert.ok(Math.abs(result.onlineBackupLayout.german.rows[0].actionsRight - result.onlineBackupLayout.english.rows[0].actionsRight) <= 1); + assert.equal(result.onlineBackupNarrowLayout.horizontalOverflow, false); + assert.equal(result.onlineBackupNarrowLayout.rowOverflow, false); + assert.equal(result.onlineBackupNarrowLayout.stacked, true); + assert.equal(result.onlineBackupNarrowLayout.actionsStretched, true); + assert.equal(result.onlineBackupNarrowLayout.createStretched, true); } finally { fs.rmSync(probeRoot, { recursive: true, force: true }); } diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 923d73b..442c72b 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -61,8 +61,41 @@ const initialIpcHandlers = new Map(); const registerIpcHandler = ipcMain.handle.bind(ipcMain); let initialConfigReadDelayed = false; let startupLanguagePendingSnapshot = null; +let managedOnlineBackupEntries = [ + { id: 'AAAAAAAAAAAAAAAAAAAAAA', displayKey: 'MHU2-ABCD…1234', createdAt: '2026-08-20T08:00:00.000Z' }, + { id: 'BBBBBBBBBBBBBBBBBBBBBB', displayKey: 'MHU2-ZYXW…9876', createdAt: '2026-08-22T10:00:00.000Z' } +]; +const managedOnlineBackupCopyIds = []; +const managedOnlineBackupDeleteIds = []; +let managedOnlineBackupCreateCalls = 0; +let managedOnlineBackupDeleteMode = 'failure'; +let releaseManagedOnlineBackupDelete = null; +const managedOnlineBackupHandlers = { + 'online-backup:list-managed': async () => ({ ok: true, entries: managedOnlineBackupEntries.map(entry => ({ ...entry })) }), + 'online-backup:create-managed': async () => { + managedOnlineBackupCreateCalls++; + const entry = { id: 'CCCCCCCCCCCCCCCCCCCCCC', displayKey: 'MHU2-QWER…4321', createdAt: '2026-08-23T12:00:00.000Z' }; + managedOnlineBackupEntries = [...managedOnlineBackupEntries, entry]; + return { ok: true, entry: { ...entry } }; + }, + 'online-backup:copy-managed': async (_event, id) => { + managedOnlineBackupCopyIds.push(id); + return { ok: true }; + }, + 'online-backup:delete-managed': async (_event, id) => { + managedOnlineBackupDeleteIds.push(id); + if (managedOnlineBackupDeleteMode === 'failure') return { ok: false, error: 'Online-Sicherung konnte nicht gelöscht werden' }; + return new Promise(resolve => { + releaseManagedOnlineBackupDelete = () => { + managedOnlineBackupEntries = managedOnlineBackupEntries.filter(entry => entry.id !== id); + releaseManagedOnlineBackupDelete = null; + resolve({ ok: true, removedId: id, notFound: false }); + }; + }); + } +}; ipcMain.handle = (channel, listener) => { - const registeredListener = channel === 'get-config' + const defaultListener = channel === 'get-config' ? async (...args) => { const result = await listener(...args); if (!initialConfigReadDelayed) { @@ -86,6 +119,7 @@ ipcMain.handle = (channel, listener) => { return result; } : listener; + const registeredListener = managedOnlineBackupHandlers[channel] || defaultListener; if (!initialIpcHandlers.has(channel)) initialIpcHandlers.set(channel, registeredListener); return registerIpcHandler(channel, registeredListener); }; @@ -1371,17 +1405,47 @@ setTimeout(async () => { check('Global parallel uploads default 0', parallel === '0'); await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'backup\\']")?.click()'); - const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "onlineBackupKeyOutput", "copyOnlineBackupKeyBtn", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id)))'); + const onlineBackupControls = await wc.executeJavaScript('["createOnlineBackupBtn", "managedOnlineBackupHeading", "managedOnlineBackupList", "onlineBackupKeyInput", "restoreOnlineBackupBtn", "onlineBackupStatus"].every(id => Boolean(document.getElementById(id))) && !document.getElementById("onlineBackupKeyOutput") && !document.getElementById("copyOnlineBackupKeyBtn")'); check('Online backup controls exist', onlineBackupControls); const onlineBackupKeyContract = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput")?.maxLength + "|" + document.getElementById("onlineBackupKeyInput")?.getAttribute("pattern")'); check('Online backup input enforces the 75-character MHU key format', onlineBackupKeyContract === '75|MHU2-[A-Za-z0-9_-]{70}'); - const onlineBackupBridge = await wc.executeJavaScript('["listManagedOnlineBackups", "createManagedOnlineBackup", "copyManagedOnlineBackup", "deleteManagedOnlineBackup"].map(name => typeof window.api[name]).join("|")'); - check('Online backup uses a narrow managed preload bridge', onlineBackupBridge === 'function|function|function|function'); + const onlineBackupBridge = await wc.executeJavaScript('["listManagedOnlineBackups", "createManagedOnlineBackup", "copyManagedOnlineBackup", "deleteManagedOnlineBackup", "restoreOnlineBackup", "createOnlineBackup"].map(name => typeof window.api[name]).join("|")'); + check('Online backup uses a narrow managed preload bridge', onlineBackupBridge === 'function|function|function|function|function|undefined'); - const managedOnlineBackupCopyResult = await wc.executeJavaScript('window.api.copyManagedOnlineBackup("AAAAAAAAAAAAAAAAAAAAAA").then(result => JSON.stringify(result))'); - check('Managed online backup copy never returns a complete key', !managedOnlineBackupCopyResult.includes('MHU2-')); + const managedOnlineBackupLoaded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 2')); + const managedOnlineBackupInitialState = await wc.executeJavaScript('(() => ({ keys: [...document.querySelectorAll(".online-backup-managed-key")].map(element => element.textContent), secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()'); + check('Managed online backups render newest first without a complete key in the DOM', managedOnlineBackupLoaded === true && managedOnlineBackupInitialState.keys.join('|') === 'MHU2-ZYXW…9876|MHU2-ABCD…1234' && managedOnlineBackupInitialState.secretInBody === false); + + await wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-copy-btn").click()'); + await waitUntil(() => managedOnlineBackupCopyIds.length === 1); + check('Managed online backup copy passes only the selected sanitized entry ID', managedOnlineBackupCopyIds.length === 1 && managedOnlineBackupCopyIds[0] === 'BBBBBBBBBBBBBBBBBBBBBB'); + + await wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-delete-btn").click()'); + await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); + const managedDeleteConfirmation = await wc.executeJavaScript('(() => ({ danger: document.getElementById("appAlertConfirmBtn").classList.contains("btn-danger"), message: document.getElementById("appAlertMessage").textContent }))()'); + await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); + await waitUntil(() => managedOnlineBackupDeleteIds.length === 1); + await waitUntil(() => wc.executeJavaScript('document.getElementById("onlineBackupStatus")?.textContent === "Online-Sicherung konnte nicht gelöscht werden"')); + const managedDeleteFailureState = await wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length'); + check('Managed online backup deletion uses danger confirmation and preserves rows on failure', managedDeleteConfirmation.danger === true && managedDeleteConfirmation.message === 'Dieses verschlüsselte Online-Backup wird dauerhaft vom Server gelöscht.' && managedDeleteFailureState === 2); + + managedOnlineBackupDeleteMode = 'success'; + await wc.executeJavaScript('document.querySelector(".online-backup-managed-row .online-backup-delete-btn").click()'); + await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); + await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); + await waitUntil(() => typeof releaseManagedOnlineBackupDelete === 'function'); + const managedDeletePendingState = await wc.executeJavaScript('(() => { const row = document.querySelector(".online-backup-managed-row"); return { count: document.querySelectorAll(".online-backup-managed-row").length, controlsDisabled: [...row.querySelectorAll("button")].every(button => button.disabled) }; })()'); + releaseManagedOnlineBackupDelete(); + const managedDeleteSucceeded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 1')); + const managedDeleteSuccessState = await wc.executeJavaScript('(() => ({ key: document.querySelector(".online-backup-managed-key")?.textContent, status: document.getElementById("onlineBackupStatus")?.textContent, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()'); + check('Managed online backup row disappears only after successful deletion', managedDeletePendingState.count === 2 && managedDeletePendingState.controlsDisabled === true && managedDeleteSucceeded === true && managedDeleteSuccessState.key === 'MHU2-ABCD…1234' && managedDeleteSuccessState.status === 'Schlüssel gelöscht' && managedDeleteSuccessState.secretInBody === false); + + await wc.executeJavaScript('document.getElementById("createOnlineBackupBtn").click()'); + const managedCreateSucceeded = await waitUntil(() => wc.executeJavaScript('document.querySelectorAll(".online-backup-managed-row").length === 2')); + const managedCreateState = await wc.executeJavaScript('(() => ({ first: document.querySelector(".online-backup-managed-key")?.textContent, secretInBody: /MHU2-[A-Za-z0-9_-]{70}/.test(document.body.textContent) }))()'); + check('Creating a managed online backup inserts the returned sanitized entry newest first', managedOnlineBackupCreateCalls === 1 && managedCreateSucceeded === true && managedCreateState.first === 'MHU2-QWER…4321' && managedCreateState.secretInBody === false); const invalidOnlineBackup = await wc.executeJavaScript('document.getElementById("onlineBackupKeyInput").value = "MHU2-short"; document.getElementById("onlineBackupKeyInput").dispatchEvent(new Event("input", { bubbles: true })); document.getElementById("restoreOnlineBackupBtn").disabled + "|" + document.getElementById("onlineBackupStatus").textContent'); check('Invalid online backup keys stay blocked with visible guidance', invalidOnlineBackup === 'true|Der Schlüssel muss exakt 75 Zeichen lang sein.'); @@ -2485,7 +2549,7 @@ setTimeout(async () => { check('Minimum Accounts keeps auto-check reachable and content contained', minimumResponsiveContract.autoCheckVisible && minimumResponsiveContract.accountsMainFits); check('Minimum Uploads preserves availability and telemetry information', minimumResponsiveContract.telemetryVisible && minimumResponsiveContract.availabilityVisible); - const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: "\\\\n\\\\n\\\\n## What\'s new\\\\n\\\\n\\\\n### Menus and navigation\\\\n\\\\n- Added live language switching.\\\\n- Improved settings layout.\\\\n\\\\n\\\\n" }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()'); + const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: "\\\\n\\\\n\\\\n## What\\'s new\\\\n\\\\n\\\\n### Menus and navigation\\\\n\\\\n- Added live language switching.\\\\n- Improved settings layout.\\\\n\\\\n\\\\n" }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()'); check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex'); await new Promise(resolve => setTimeout(resolve, 100));