Compare commits

...

2 Commits

Author SHA1 Message Date
Administrator
d9e858febd release: v3.3.38 2026-06-07 03:11:58 +02:00
Administrator
e26b7ea8ed fix(accounts): never persist unverified creds + dedupe-proof modal + label + perf
User reported three coupled bugs in account add/edit:
  (1) Invalid logins still create the account
  (2) Doodstream gets created multiple times when "Prüfen & Anlegen" is
      double-clicked or repeatedly OTP-retried
  (3) Add/Delete in the accounts panel feel laggy
Plus a UX/feature request: account label + two-step "Prüfen → Anlegen" flow.

Map (workflow wf44zpud4, 3 parallel subagents + adversarial verify) confirmed:
- saveAccount() persisted to disk BEFORE the health check (lines 3407-3409)
- saveBtn.disabled was set AFTER two awaited IPC roundtrips → 5-100ms race window
- OTP-retry path generated a new accountId on every click (editingAccountId
  stayed null in ADD mode) → DETERMINISTIC duplication on every OTP attempt
- runHealthCheck IPC required the account to be already persisted → that's
  why the old code wrote-first-check-second

Fix architecture (advisor: Option A — make the invariant real, not cleanup-based):
- main.js + preload.js: NEW `validate-credentials` IPC. Accepts ephemeral
  {hoster, authType, username, password, apiKey, otp} payload, builds an
  ephemeral hosterConfig, runs the same per-hoster checker via a shared
  _dispatchHealthCheck helper. Nothing touches config.hosters.
- renderer: two-step modal state machine.
    - "Prüfen" click → validateCredentials (ephemeral) → green flips button to
      "Anlegen"/"Speichern" AND caches a snapshot of the validated creds.
    - "Anlegen"/"Speichern" click → only fires if cached snapshot matches the
      currently-typed credential-identity (username+password or apiKey;
      label and OTP are not part of the snapshot key).
    - Input listeners on the identity fields drop the snapshot the moment any
      cred is edited post-green → user can't sneak unverified creds through.
    - _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler,
      before any await, so a double-click is a no-op.
    - _accountModalSession token bumps on every modal reset → a stale late
      response from a closed-and-reopened modal can't stomp the new session's
      busy flag or UI (lens-2 review fix).
    - Edit mode flows through the same path → bad edits never reach disk
      before being validated (fixes the silent good-creds clobber).
    - closeAccountModal cancels the auto-close timer + clears modal state so
      a stale 600 ms timer can't close a freshly-reopened modal.
- Label field (new): persisted on the account, shown in the card subtitle as
  "Label: XYZ • API: ABC… — API Key gültig" so identical-looking API accounts
  are disambiguable. Excluded from snapshot key on purpose — label is metadata.
- Perf: drop the redundant `await getConfig()` round-trip in commit+delete
  (in-memory state was already the source of truth and the old reload was the
  main lag source). deleteAccount fires-and-forgets the saveConfig and closes
  the modal synchronously. Commit path uses updateAccountCard for the
  single-card edit case instead of a 4-panel cascade.

Multi-lens review (workflow wyoc3iq4k, 3 reviewers): OTP-correctness SHIP,
race-guard SHIP-WITH-FIXES (session-id token + busy-inside-try applied),
edit-mode+label SHIP. No blockers.

Tests: 6 new regression tests (tests/validate-credentials.test.js) covering
the three reported bugs as executable spec:
  (a) failed validation persists nothing to config.hosters
  (b) second click with guard set persists exactly one entry
  (c) OTP-required persists nothing; OTP retry re-validates ephemerally
plus snapshot-key identity, post-validation edit invalidation, and the
ephemeral hosterConfig shape contract. 210/210 green, lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 03:11:13 +02:00
6 changed files with 488 additions and 93 deletions

46
main.js
View File

@ -1115,6 +1115,52 @@ ipcMain.handle('run-health-check', async (_event, payload) => {
return runHosterHealthCheck(config, hosters); return runHosterHealthCheck(config, hosters);
}); });
// Validate ephemeral credentials WITHOUT persisting them to config.hosters.
// This is the IPC that backs the two-step "Prüfen → Anlegen" modal flow: the
// new account is never on disk until the user confirms after a green check, so
// failed/OTP-pending creds can't leak into config (and a double-click on the
// Prüfen button cannot create duplicates because nothing is written until the
// second, distinct "Anlegen" click). NOTE: this payload carries plaintext creds
// across the IPC boundary — same trust level as save-config — DO NOT log it.
ipcMain.handle('validate-credentials', async (_event, payload) => {
if (!payload || !payload.hoster) {
return { status: 'error', message: 'Hoster fehlt' };
}
const ephemeralHosterConfig = {
username: payload.username || '',
password: payload.password || '',
apiKey: payload.apiKey || '',
enabled: true
};
try {
return await _dispatchHealthCheck(payload.hoster, ephemeralHosterConfig, payload.otp || '');
} catch (err) {
return { status: 'error', message: err && err.message ? err.message : 'Validierung fehlgeschlagen' };
}
});
async function _dispatchHealthCheck(hoster, hosterConfig, otp) {
// Mirrors the per-hoster switch in runHosterHealthCheck so both code paths
// (batch check by accountId and ephemeral validate) go through identical
// checkers + timeout wrappers and surface identical result shapes.
if (hoster === 'doodstream.com') {
return withTimeout(checkDoodstreamHealth(hosterConfig, otp), HEALTH_CHECK_TIMEOUT, 'Doodstream-Check');
}
if (hoster === 'vidmoly.me') {
return withTimeout(checkVidmolyHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Vidmoly-Check');
}
if (hoster === 'voe.sx') {
return withTimeout(checkVoeHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'VOE-Check');
}
if (hoster === 'byse.sx') {
return withTimeout(checkByseHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Byse-Check');
}
if (hoster === 'clouddrop.cc') {
return withTimeout(checkClouddropHealth(hosterConfig), HEALTH_CHECK_TIMEOUT, 'Clouddrop-Check');
}
return { status: 'skipped', message: 'Kein Health-Check fuer diesen Hoster' };
}
ipcMain.handle('select-files', async () => { ipcMain.handle('select-files', async () => {
const result = await dialog.showOpenDialog(mainWindow, { const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openFile', 'multiSelections'], properties: ['openFile', 'multiSelections'],

View File

@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "3.3.37", "version": "3.3.38",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {

View File

@ -39,6 +39,7 @@ contextBridge.exposeInMainWorld('api', {
addJobsToBatch: (payload) => ipcRenderer.invoke('add-jobs-to-batch', payload), addJobsToBatch: (payload) => ipcRenderer.invoke('add-jobs-to-batch', payload),
finishAfterActive: () => ipcRenderer.invoke('finish-after-active'), finishAfterActive: () => ipcRenderer.invoke('finish-after-active'),
runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload), runHealthCheck: (payload) => ipcRenderer.invoke('run-health-check', payload),
validateCredentials: (payload) => ipcRenderer.invoke('validate-credentials', payload),
// Log import // Log import
readOwnUploadLog: () => ipcRenderer.invoke('read-own-upload-log'), readOwnUploadLog: () => ipcRenderer.invoke('read-own-upload-log'),

View File

@ -3040,6 +3040,11 @@ function _buildAccountCardHtml(name, account, idx) {
const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft'); const statusLabel = isDisabled ? 'Deaktiviert' : (_STATUS_LABELS[st.status] || 'Nicht geprüft');
const statusClass = isDisabled ? 'disabled' : st.status; const statusClass = isDisabled ? 'disabled' : st.status;
const credLabel = getCredentialLabel(name, account); const credLabel = getCredentialLabel(name, account);
const userLabel = account.label && String(account.label).trim();
// Subtitle: "Label: XYZ • API: ABC… • <status>" — the user-set label is the
// disambiguator for accounts that otherwise look identical (e.g. two byse
// API-key accounts where you can't tell what's what from the masked key).
const subtitleText = (userLabel ? `Label: ${userLabel}` : '') + credLabel;
const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren';
const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`;
@ -3048,7 +3053,7 @@ function _buildAccountCardHtml(name, account, idx) {
<div class="account-card-drag-handle" title="Ziehen zum Sortieren">&#9776;</div> <div class="account-card-drag-handle" title="Ziehen zum Sortieren">&#9776;</div>
<div class="account-card-info"> <div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div> <div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span></div>
<div class="account-card-subtitle" title="${escapeAttr(credLabel)}">${escapeHtml(credLabel)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div> <div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}</div>
</div> </div>
<span class="account-status status-${statusClass}"> <span class="account-status status-${statusClass}">
<span class="account-status-dot"></span> <span class="account-status-dot"></span>
@ -3268,6 +3273,9 @@ function getCredsFieldsHtml(authType, account, hoster) {
function openAccountModal(editAccountId) { function openAccountModal(editAccountId) {
editingAccountId = editAccountId || null; editingAccountId = editAccountId || null;
// Reset the two-step state — any previously validated snapshot from a prior
// modal session is stale and must not allow a no-recheck commit.
_resetAccountModalState();
const modal = document.getElementById('accountModal'); const modal = document.getElementById('accountModal');
const title = document.getElementById('accountModalTitle'); const title = document.getElementById('accountModalTitle');
const subtitle = document.getElementById('accountModalSubtitle'); const subtitle = document.getElementById('accountModalSubtitle');
@ -3276,6 +3284,7 @@ function openAccountModal(editAccountId) {
const credsContainer = document.getElementById('accountCredsFields'); const credsContainer = document.getElementById('accountCredsFields');
const statusEl = document.getElementById('accountModalStatus'); const statusEl = document.getElementById('accountModalStatus');
const saveBtn = document.getElementById('saveAccountBtn'); const saveBtn = document.getElementById('saveAccountBtn');
const labelInput = document.getElementById('accField_label');
statusEl.textContent = ''; statusEl.textContent = '';
statusEl.className = 'account-modal-status'; statusEl.className = 'account-modal-status';
@ -3287,18 +3296,20 @@ function openAccountModal(editAccountId) {
title.textContent = 'Account bearbeiten'; title.textContent = 'Account bearbeiten';
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`; subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
hosterRow.style.display = 'none'; hosterRow.style.display = 'none';
saveBtn.textContent = 'Speichern & prüfen'; saveBtn.textContent = 'Prüfen';
if (labelInput) labelInput.value = found.account.label || '';
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name); credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
} else { } else {
// Add mode — always show all options (multiple accounts per hoster allowed) // Add mode — always show all options (multiple accounts per hoster allowed)
title.textContent = 'Account hinzufügen'; title.textContent = 'Account hinzufügen';
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein.'; subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".';
hosterRow.style.display = 'flex'; hosterRow.style.display = 'flex';
saveBtn.textContent = 'Anlegen & prüfen'; saveBtn.textContent = 'Prüfen';
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt => hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
`<option value="${opt.value}">${escapeHtml(opt.label)}</option>` `<option value="${opt.value}">${escapeHtml(opt.label)}</option>`
).join(''); ).join('');
const firstOpt = HOSTER_ADD_OPTIONS[0]; const firstOpt = HOSTER_ADD_OPTIONS[0];
if (labelInput) labelInput.value = '';
credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value); credsContainer.innerHTML = getCredsFieldsHtml(firstOpt.authType, {}, firstOpt.value);
} }
@ -3310,6 +3321,12 @@ function openAccountModal(editAccountId) {
}); });
}); });
// Wire field invalidation: any change to a cred field after a green check
// drops the validated snapshot so the next click is a re-check, not a commit
// of unverified creds. Re-wired here every open because credsContainer's HTML
// was replaced.
_wireCredFieldInvalidation();
modal.style.display = 'flex'; modal.style.display = 'flex';
} }
@ -3317,6 +3334,11 @@ function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none'; document.getElementById('accountModal').style.display = 'none';
_hideOtpField(); _hideOtpField();
editingAccountId = null; editingAccountId = null;
// Cancel any pending auto-close so a stale timer can't close a future modal
// the user reopens within the auto-close window.
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
_validatedCreds = null;
_accountModalBusy = false;
} }
function openDeleteAccountModal(accountId) { function openDeleteAccountModal(accountId) {
@ -3342,130 +3364,249 @@ async function deleteAccount(accountId) {
config.hosters[found.name] = accounts.filter(a => a.id !== accountId); config.hosters[found.name] = accounts.filter(a => a.id !== accountId);
} }
delete accountStatuses[accountId]; delete accountStatuses[accountId];
await window.api.saveConfig({ hosters: config.hosters }); // saveConfig is async — close the modal immediately so the UI feels
config = await window.api.getConfig(); // responsive instead of waiting for the atomic write + safeStorage encrypt.
// The in-memory config already reflects the delete; the IPC just persists it.
closeDeleteModal();
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters(); syncSelectedUploadHosters();
if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]); if (getAllAccountsFlat().length === 0) renderHealthCheckResults([]);
renderAccounts(); renderAccounts();
renderHosterSummary(); renderHosterSummary();
renderHosterModal(); // Fire-and-forget the persist. The earlier `await getConfig()` round-trip
renderSettings(); // was redundant (we already have the truth in memory) and was the main
closeDeleteModal(); // source of perceived lag on add/delete.
window.api.saveConfig({ hosters: config.hosters }).catch(() => {});
} }
function readAccountCredsFromModal(authType) { function readAccountCredsFromModal(authType) {
const label = (document.getElementById('accField_label')?.value || '').trim();
if (authType === 'login') { if (authType === 'login') {
const username = (document.getElementById('accField_username')?.value || '').trim(); const username = (document.getElementById('accField_username')?.value || '').trim();
const password = (document.getElementById('accField_password')?.value || '').trim(); const password = (document.getElementById('accField_password')?.value || '').trim();
return { enabled: !!(username && password), authType: 'login', username, password }; return { enabled: !!(username && password), authType: 'login', username, password, label };
} }
// API // API
const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim(); const apiKey = (document.getElementById('accField_apiKey')?.value || '').trim();
return { enabled: !!apiKey, authType: 'api', apiKey }; return { enabled: !!apiKey, authType: 'api', apiKey, label };
}
// --- Two-step account-modal state machine ---
//
// Goal: never persist invalid/unverified credentials to config.hosters. The
// user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green
// the button label flips to "Anlegen" / "Speichern" → the next click commits
// to config. Editing any cred field between the two clicks drops the validated
// snapshot so the user can't sneak unverified creds through by editing
// post-green.
//
// Invariants enforced here:
// 1. Nothing reaches config.hosters until _validatedCreds matches a green
// result for the currently-typed creds.
// 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler
// before any await — guards against double-clicks producing duplicates.
// 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with
// the new OTP, no config writes until green.
// 4. Edit mode hits the same path → bad edits never overwrite known-good
// creds on disk.
let _accountModalBusy = false;
let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green
let _autoCloseTimer = null;
// Session token used to ignore stale validate-credentials responses: if the
// user closes the modal mid-flight and reopens it, the late .then must NOT
// stomp the new session's state. Bumped on every modal reset.
let _accountModalSession = 0;
function _resetAccountModalState() {
_accountModalBusy = false;
_validatedCreds = null;
_accountModalSession++;
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
}
function _credsSnapshotKey(authType, creds) {
// Identity key for the typed creds — used to detect post-validation edits.
// Label changes do NOT invalidate (label is metadata, not a credential).
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`;
}
function _wireCredFieldInvalidation() {
// Any change to a cred IDENTITY field (username/password/apiKey) clears the
// validated snapshot and reverts the button to "Prüfen". Label edits don't
// invalidate (label is metadata, not a credential). OTP edits don't either:
// OTP is an ephemeral auth challenge — once doodstream returned "ok" for
// these username+password+OTP, the resulting trust is on the creds; the user
// clearing or fixing the OTP field afterward shouldn't force a re-prompt.
const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
for (const id of ids) {
const el = document.getElementById(id);
if (!el || el.dataset.invalidateBound === '1') continue;
el.addEventListener('input', () => {
if (_validatedCreds) {
_validatedCreds = null;
const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn) saveBtn.textContent = 'Prüfen';
const statusEl = document.getElementById('accountModalStatus');
if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; }
}
});
el.dataset.invalidateBound = '1';
}
}
function _determineHosterContext() {
if (editingAccountId) {
const found = findAccountById(editingAccountId);
if (!found) return null;
return { hosterName: found.name, authType: found.account.authType || 'login', accountId: editingAccountId, isEdit: true };
}
const selectValue = document.getElementById('accountHosterSelect')?.value;
if (!selectValue) return null;
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue);
if (!opt) return null;
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
} }
async function saveAccount() { async function saveAccount() {
let hosterName, authType, accountId; // SYNCHRONOUS re-entry guard — must come before any await. Without this a
// double-click before the first IPC returns triggers two saveAccount() calls
// and (in the old code) two pushes/two IPCs. _accountModalBusy is checked
// synchronously and set synchronously, so the second click no-ops cleanly.
if (_accountModalBusy) return;
if (editingAccountId) { const ctx = _determineHosterContext();
// Edit existing account if (!ctx) return;
const found = findAccountById(editingAccountId); const creds = readAccountCredsFromModal(ctx.authType);
if (!found) return;
hosterName = found.name;
authType = found.account.authType || 'login';
accountId = editingAccountId;
} else {
// Add new account
const selectValue = document.getElementById('accountHosterSelect')?.value;
if (!selectValue) return;
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === selectValue);
if (!opt) return;
hosterName = opt.hoster;
authType = opt.authType;
accountId = `${hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
}
const creds = readAccountCredsFromModal(authType);
if (!creds.enabled) {
const statusEl = document.getElementById('accountModalStatus'); const statusEl = document.getElementById('accountModalStatus');
const saveBtn = document.getElementById('saveAccountBtn');
if (!creds.enabled) {
statusEl.textContent = 'Bitte Zugangsdaten eingeben.'; statusEl.textContent = 'Bitte Zugangsdaten eingeben.';
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
return; return;
} }
// Save credentials // STEP 2: commit. Only fires if a previous "Prüfen" already validated the
if (!Array.isArray(config.hosters[hosterName])) config.hosters[hosterName] = []; // EXACT same creds (label changes don't break this — label isn't part of the
if (editingAccountId) { // credential identity).
// Update existing account in array const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
const idx = config.hosters[hosterName].findIndex(a => a.id === editingAccountId); if (_validatedCreds &&
if (idx >= 0) { _validatedCreds.hosterName === ctx.hosterName &&
config.hosters[hosterName][idx] = { ...config.hosters[hosterName][idx], ...creds }; _validatedCreds.authType === ctx.authType &&
} _validatedCreds.snapshot === snapshotKey) {
} else { // Set busy INSIDE the try so a sync throw on the saveBtn deref above can't
// Add new account // leak _accountModalBusy=true and lock the user out for the session.
config.hosters[hosterName].push({ id: accountId, ...creds }); try {
} _accountModalBusy = true;
await window.api.saveConfig({ hosters: config.hosters });
config = await window.api.getConfig();
// Show checking status
const statusEl = document.getElementById('accountModalStatus');
const saveBtn = document.getElementById('saveAccountBtn');
statusEl.textContent = 'Prüfe Login...';
statusEl.className = 'account-modal-status checking';
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…';
await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message);
} finally {
_accountModalBusy = false;
if (saveBtn) saveBtn.disabled = false;
}
return;
}
accountStatuses[accountId] = { status: 'checking', message: '' }; // STEP 1: validate ephemerally. NOTHING is written to config.hosters here.
syncSelectedUploadHosters(); // Snapshot the session token so a stale late-arriving response from a
renderAccounts(); // closed-and-reopened modal can't stomp the new session's state.
renderHosterSummary(); const mySession = _accountModalSession;
renderHosterModal(); _accountModalBusy = true;
renderSettings(); saveBtn.disabled = true;
statusEl.textContent = 'Prüfe Login…';
statusEl.className = 'account-modal-status checking';
// Check if OTP was entered (for retry after OTP prompt)
const otpInput = document.getElementById('accField_otp'); const otpInput = document.getElementById('accField_otp');
const otp = otpInput ? otpInput.value.trim() : ''; const otp = otpInput ? otpInput.value.trim() : '';
const payload = {
hoster: ctx.hosterName,
authType: ctx.authType,
username: creds.username || '',
password: creds.password || '',
apiKey: creds.apiKey || '',
otp
};
// Run health check for this specific account (include OTP if provided) let row;
const checkPayload = { hoster: hosterName, accountId };
if (otp) checkPayload.otp = otp;
try { try {
const result = await window.api.runHealthCheck({ hosters: [checkPayload] }); row = await window.api.validateCredentials(payload);
const rows = result && Array.isArray(result.results) ? result.results : []; } catch (err) {
const row = rows.find(r => r.accountId === accountId); row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' };
} finally {
if (mySession === _accountModalSession) {
_accountModalBusy = false;
if (saveBtn) saveBtn.disabled = false;
}
}
// Stale response — modal was closed/reopened while we awaited. Drop it.
if (mySession !== _accountModalSession) return;
if (row && row.status === 'otp_required') { if (row && row.status === 'otp_required') {
// Show OTP input field if not already visible
accountStatuses[accountId] = { status: 'error', message: row.message || 'OTP erforderlich' };
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.'; statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
_showOtpField(); _showOtpField();
saveBtn.textContent = 'OTP bestätigen'; _wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
} else if (row && (row.status === 'ok' || row.status === 'warn')) { saveBtn.textContent = 'Mit OTP prüfen';
accountStatuses[accountId] = { status: row.status || 'ok', message: row.message || '' }; return;
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich!'; }
if (row && (row.status === 'ok' || row.status === 'warn')) {
statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.';
statusEl.className = 'account-modal-status ok'; statusEl.className = 'account-modal-status ok';
_hideOtpField(); _hideOtpField();
setTimeout(() => closeAccountModal(), 1200); _validatedCreds = {
} else { hosterName: ctx.hosterName,
authType: ctx.authType,
snapshot: snapshotKey,
status: row.status,
message: row.message || ''
};
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
return;
}
// error
const msg = (row && row.message) || 'Login fehlgeschlagen'; const msg = (row && row.message) || 'Login fehlgeschlagen';
accountStatuses[accountId] = { status: 'error', message: msg };
statusEl.textContent = msg; statusEl.textContent = msg;
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
}
async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) {
// Persist the validated creds to config.hosters and close the modal. By the
// time we reach this function the validate-credentials IPC has already
// returned ok/warn for these exact creds, so we skip a redundant re-check.
let accountId;
if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = [];
if (ctx.isEdit) {
accountId = ctx.accountId;
const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
if (idx >= 0) {
config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
} }
} catch (err) { } else {
accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
statusEl.textContent = err.message || 'Prüfung fehlgeschlagen'; config.hosters[ctx.hosterName].push({ id: accountId, ...creds });
statusEl.className = 'account-modal-status error'; }
} finally { await window.api.saveConfig({ hosters: config.hosters });
saveBtn.disabled = false; // Skip the redundant await getConfig() — the in-memory state is the source
// of truth for what we just wrote, decrypted creds didn't change, and the
// round-trip was the main lag source on add/delete.
accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters();
// Targeted updates instead of the 4-panel cascade. For add we need a full
// accounts-list re-render (new card) and the hoster summary count; for edit
// we can update the single card. Settings panel only needs re-render if its
// hoster-summary section is visible — that's covered by renderHosterSummary.
if (ctx.isEdit) {
updateAccountCard(accountId);
} else {
renderAccounts(); renderAccounts();
renderHosterSummary();
renderHosterModal();
renderSettings();
} }
renderHosterSummary();
// Auto-close after a short pause so the user sees the success state.
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
_autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600);
} }
function _showOtpField() { function _showOtpField() {
@ -3924,6 +4065,14 @@ function setupListeners() {
}); });
document.getElementById('accountModalStatus').textContent = ''; document.getElementById('accountModalStatus').textContent = '';
document.getElementById('accountModalStatus').className = 'account-modal-status'; document.getElementById('accountModalStatus').className = 'account-modal-status';
// Hoster changed → any prior validation is stale by construction. Drop the
// snapshot and revert the button so the user has to re-Prüfen.
_validatedCreds = null;
const sb = document.getElementById('saveAccountBtn');
if (sb) sb.textContent = 'Prüfen';
// The cred inputs were just replaced — rewire invalidation listeners on
// the fresh elements so post-validation edits still revert the button.
_wireCredFieldInvalidation();
}); });
// Delete account modal // Delete account modal

View File

@ -187,6 +187,10 @@
<label>Hoster</label> <label>Hoster</label>
<select class="key-input" id="accountHosterSelect" style="max-width:300px"></select> <select class="key-input" id="accountHosterSelect" style="max-width:300px"></select>
</div> </div>
<div class="settings-row">
<label>Label (optional)</label>
<input type="text" class="key-input" id="accField_label" placeholder="z.B. Hauptaccount, Premium, Kunde XY" maxlength="60">
</div>
<div id="accountCredsFields"></div> <div id="accountCredsFields"></div>
<div class="account-modal-status" id="accountModalStatus"></div> <div class="account-modal-status" id="accountModalStatus"></div>
</div> </div>

View File

@ -0,0 +1,195 @@
// Pure unit tests for the validate-credentials shape contract — does NOT spin
// up Electron or the real per-hoster checkers. Those need network. We verify
// the SHAPE the ephemeral hosterConfig is built into (which the per-hoster
// checkers consume) plus the snapshot-key/invalidation invariants that the
// renderer relies on to enforce "validated creds only".
//
// The three assertions the advisor called out as the regression guard for the
// user's "mehrfach angelegt" complaint:
// (a) failed validation persists nothing to config.hosters
// (b) a second "Anlegen" click with the guard set persists exactly one entry
// (c) OTP-required path persists nothing
// are exercised at the state-machine level by simulating the renderer's logic
// (re-implemented here as pure functions for testability — the real ones live
// in renderer/app.js which can't run under node:test).
const { test } = require('node:test');
const assert = require('node:assert');
// ---- Re-implementations of the renderer's pure helpers ----
// These mirror the production code exactly so the tests serve as both a guard
// and executable spec for what saveAccount() must do.
function credsSnapshotKey(authType, creds) {
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
return `api:${creds.apiKey || ''}`;
}
function buildEphemeralHosterConfig(payload) {
return {
username: payload.username || '',
password: payload.password || '',
apiKey: payload.apiKey || '',
enabled: true
};
}
// State-machine simulator that mirrors saveAccount() WITHOUT DOM/IPC.
function makeStateMachine({ validateImpl, persistImpl }) {
let busy = false;
let validated = null; // { hosterName, authType, snapshot, status }
const log = []; // log of every persist call, for assertions
async function click(ctx, creds, otp = '') {
if (busy) { log.push({ type: 'click-ignored-busy' }); return; }
const snapshot = credsSnapshotKey(ctx.authType, creds);
// STEP 2: commit if validated matches.
if (validated &&
validated.hosterName === ctx.hosterName &&
validated.authType === ctx.authType &&
validated.snapshot === snapshot) {
busy = true;
try {
await persistImpl(ctx, creds);
log.push({ type: 'persisted', accountId: ctx.accountId || `${ctx.hosterName}-NEW` });
} finally { busy = false; }
return;
}
// STEP 1: ephemeral validate.
busy = true;
let row;
try {
row = await validateImpl({ hoster: ctx.hosterName, authType: ctx.authType, ...creds, otp });
} finally { busy = false; }
if (row && (row.status === 'ok' || row.status === 'warn')) {
validated = { hosterName: ctx.hosterName, authType: ctx.authType, snapshot, status: row.status };
log.push({ type: 'validated', status: row.status });
return;
}
if (row && row.status === 'otp_required') {
log.push({ type: 'otp-required' });
return;
}
log.push({ type: 'validation-failed', message: row && row.message });
}
function editField() { validated = null; log.push({ type: 'invalidated-by-edit' }); }
return { click, editField, log: () => log.slice(), getValidated: () => validated };
}
// ---- Tests ----
test('regression (a): failed validation persists NOTHING to config.hosters', async () => {
const persistCalls = [];
const sm = makeStateMachine({
validateImpl: async () => ({ status: 'error', message: 'Falsches Passwort' }),
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
await sm.click({ hosterName: 'doodstream.com', authType: 'login', isEdit: false }, { username: 'u', password: 'wrong' });
assert.equal(persistCalls.length, 0, 'no persist should happen on failed validation');
assert.equal(sm.getValidated(), null);
assert.deepEqual(sm.log().map(e => e.type), ['validation-failed']);
});
test('regression (b): second click with guard set persists exactly ONE entry — no duplication', async () => {
const persistCalls = [];
let validateCount = 0;
const sm = makeStateMachine({
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
const creds = { username: 'u', password: 'p' };
// Click 1 = validate → green.
await sm.click(ctx, creds);
// Click 2 = commit (same creds, validated snapshot matches).
await sm.click(ctx, creds);
// Click 3 = guard prevents a second commit because after persistImpl the
// state-machine in real code closes the modal. In this simulator the
// validated snapshot is still set — but a real double-click WHILE persistImpl
// is in flight would be caught by busy. Simulate that:
const sm2 = makeStateMachine({
validateImpl: async () => ({ status: 'ok' }),
persistImpl: () => new Promise(r => setTimeout(() => { persistCalls.push('slow'); r(); }, 30))
});
await sm2.click(ctx, creds); // validate
const p1 = sm2.click(ctx, creds); // start commit
const p2 = sm2.click(ctx, creds); // racing click — must be ignored
await Promise.all([p1, p2]);
assert.equal(persistCalls.length, 2, 'one persist from the deliberate two-step flow + one from sm2; racing click ignored');
assert.equal(validateCount, 1, 'second click reused the validated snapshot — no re-validate');
// The racing click MUST have been ignored by the busy guard.
assert.ok(sm2.log().some(e => e.type === 'click-ignored-busy'), 'busy guard fired on racing click');
});
test('regression (c): OTP-required persists NOTHING — and a follow-up click with OTP re-validates ephemerally', async () => {
const persistCalls = [];
let calls = 0;
const sm = makeStateMachine({
validateImpl: async (payload) => {
calls++;
if (!payload.otp) return { status: 'otp_required', message: 'OTP sent' };
if (payload.otp === '123456') return { status: 'ok' };
return { status: 'error', message: 'Bad OTP' };
},
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
const creds = { username: 'u', password: 'p' };
await sm.click(ctx, creds, ''); // first click → otp_required
await sm.click(ctx, creds, '123456'); // retry with otp → ok
await sm.click(ctx, creds); // final click → commit
assert.equal(persistCalls.length, 1, 'exactly one persist after OTP confirmed');
assert.equal(calls, 2, 'validate ran twice (initial + OTP) before commit');
assert.deepEqual(
sm.log().map(e => e.type),
['otp-required', 'validated', 'persisted']
);
});
test('field edit after green check invalidates the snapshot — next click is a re-Prüfen, not a commit', async () => {
const persistCalls = [];
let validateCount = 0;
const sm = makeStateMachine({
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
});
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
sm.editField(); // user edits cred field → snapshot dropped
await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate
await sm.click(ctx, { username: 'u', password: 'newpw' }); // now commit the NEW creds
assert.equal(persistCalls.length, 1, 'one persist of the new (re-validated) creds');
assert.equal(persistCalls[0].creds.password, 'newpw', 'persisted creds match the re-validated set');
assert.equal(validateCount, 2, 'second validate was forced by the edit-induced invalidation');
});
test('snapshot key is identical for same creds and DIFFERENT for any cred change (excluding label)', () => {
// Label changes must NOT invalidate validation — label is metadata, not a credential.
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' }));
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' }));
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
credsSnapshotKey('api', { apiKey: 'KEY2' }));
});
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => {
// The per-hoster checkers in main.js read .username/.password/.apiKey directly.
// This guards the validate-credentials IPC contract from drifting.
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' });
assert.equal(cfg.username, 'u');
assert.equal(cfg.password, 'p');
assert.equal(cfg.apiKey, '');
assert.equal(cfg.enabled, true);
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' });
assert.equal(cfg2.apiKey, 'K');
assert.equal(cfg2.username, '');
});