feat(accounts): validate and save in one action
Keep account validation and persistence inside one guarded submission. Recheck modal identity before committing, persist a copied hoster candidate, and publish renderer state only after saveConfig succeeds.
This commit is contained in:
parent
b075961802
commit
0652edf69f
29
renderer/account-submit.js
Normal file
29
renderer/account-submit.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
(function (scope) {
|
||||||
|
async function submitValidatedAccount({ validate, commit, isCurrent }) {
|
||||||
|
let validation;
|
||||||
|
try {
|
||||||
|
validation = await validate();
|
||||||
|
} catch (error) {
|
||||||
|
return { status: 'error', error };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrent()) return { status: 'stale', validation };
|
||||||
|
if (validation && validation.status === 'otp_required') {
|
||||||
|
return { status: 'otp_required', validation };
|
||||||
|
}
|
||||||
|
if (!validation || (validation.status !== 'ok' && validation.status !== 'warn')) {
|
||||||
|
return { status: 'rejected', validation };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await commit(validation);
|
||||||
|
return { status: 'committed', validation };
|
||||||
|
} catch (error) {
|
||||||
|
return { status: 'error', error, validation };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountSubmit = { submitValidatedAccount };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = accountSubmit;
|
||||||
|
if (scope) scope.AccountSubmit = accountSubmit;
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
271
renderer/app.js
271
renderer/app.js
@ -4260,8 +4260,6 @@ 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();
|
_resetAccountModalState();
|
||||||
const modal = document.getElementById('accountModal');
|
const modal = document.getElementById('accountModal');
|
||||||
const title = document.getElementById('accountModalTitle');
|
const title = document.getElementById('accountModalTitle');
|
||||||
@ -4281,17 +4279,17 @@ function openAccountModal(editAccountId) {
|
|||||||
const found = findAccountById(editingAccountId);
|
const found = findAccountById(editingAccountId);
|
||||||
if (!found) return;
|
if (!found) return;
|
||||||
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 und prüfen.`;
|
||||||
hosterRow.style.display = 'none';
|
hosterRow.style.display = 'none';
|
||||||
saveBtn.textContent = 'Prüfen';
|
saveBtn.textContent = 'Prüfen & speichern';
|
||||||
if (labelInput) labelInput.value = found.account.label || '';
|
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. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".';
|
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.';
|
||||||
hosterRow.style.display = 'flex';
|
hosterRow.style.display = 'flex';
|
||||||
saveBtn.textContent = 'Prüfen';
|
saveBtn.textContent = 'Anlegen & 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('');
|
||||||
@ -4308,10 +4306,6 @@ 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();
|
_wireCredFieldInvalidation();
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
@ -4321,11 +4315,7 @@ 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
|
_resetAccountModalState();
|
||||||
// the user reopens within the auto-close window.
|
|
||||||
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
|
||||||
_validatedCreds = null;
|
|
||||||
_accountModalBusy = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeleteAccountModal(accountId) {
|
function openDeleteAccountModal(accountId) {
|
||||||
@ -4381,66 +4371,61 @@ function readAccountCredsFromModal(authType) {
|
|||||||
return { enabled: !!apiKey, authType: 'api', apiKey, label };
|
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 _accountModalBusy = false;
|
||||||
let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green
|
let _accountModalBusySession = null;
|
||||||
let _autoCloseTimer = null;
|
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;
|
let _accountModalSession = 0;
|
||||||
|
|
||||||
function _resetAccountModalState() {
|
function _resetAccountModalState() {
|
||||||
_accountModalBusy = false;
|
|
||||||
_validatedCreds = null;
|
|
||||||
_accountModalSession++;
|
_accountModalSession++;
|
||||||
|
_accountModalBusy = false;
|
||||||
|
_accountModalBusySession = null;
|
||||||
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
|
||||||
|
const saveBtn = document.getElementById('saveAccountBtn');
|
||||||
|
if (saveBtn) saveBtn.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _credsSnapshotKey(authType, creds) {
|
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 || ''}`;
|
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
|
||||||
return `api:${creds.apiKey || ''}`;
|
return `api:${creds.apiKey || ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _defaultAccountSubmitButtonText(ctx, hasOtp) {
|
||||||
|
if (hasOtp) return ctx && ctx.isEdit ? 'Mit OTP prüfen & speichern' : 'Mit OTP anlegen & prüfen';
|
||||||
|
return ctx && ctx.isEdit ? 'Prüfen & speichern' : 'Anlegen & prüfen';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _invalidateAccountSubmit() {
|
||||||
|
_accountModalSession++;
|
||||||
|
const statusEl = document.getElementById('accountModalStatus');
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.textContent = '';
|
||||||
|
statusEl.className = 'account-modal-status';
|
||||||
|
}
|
||||||
|
const saveBtn = document.getElementById('saveAccountBtn');
|
||||||
|
if (saveBtn && !_accountModalBusy) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext(), !!document.getElementById('accField_otp'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _releaseAccountSubmit(session) {
|
||||||
|
if (_accountModalBusySession !== session) return;
|
||||||
|
_accountModalBusy = false;
|
||||||
|
_accountModalBusySession = null;
|
||||||
|
const saveBtn = document.getElementById('saveAccountBtn');
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext(), !!document.getElementById('accField_otp'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function _wireCredFieldInvalidation() {
|
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'];
|
const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (!el || el.dataset.invalidateBound === '1') continue;
|
if (!el || el.dataset.invalidateBound === '1') continue;
|
||||||
el.addEventListener('input', () => {
|
el.addEventListener('input', _invalidateAccountSubmit);
|
||||||
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';
|
el.dataset.invalidateBound = '1';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4458,11 +4443,17 @@ function _determineHosterContext() {
|
|||||||
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
|
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _isAccountSubmitCurrent(session, ctx, snapshotKey) {
|
||||||
|
if (session !== _accountModalSession) return false;
|
||||||
|
const currentCtx = _determineHosterContext();
|
||||||
|
if (!currentCtx) return false;
|
||||||
|
if (currentCtx.hosterName !== ctx.hosterName || currentCtx.authType !== ctx.authType) return false;
|
||||||
|
if (currentCtx.accountId !== ctx.accountId || currentCtx.isEdit !== ctx.isEdit) return false;
|
||||||
|
const currentCreds = readAccountCredsFromModal(currentCtx.authType);
|
||||||
|
return _credsSnapshotKey(currentCtx.authType, currentCreds) === snapshotKey;
|
||||||
|
}
|
||||||
|
|
||||||
async function saveAccount() {
|
async function saveAccount() {
|
||||||
// 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 (_accountModalBusy) return;
|
||||||
|
|
||||||
const ctx = _determineHosterContext();
|
const ctx = _determineHosterContext();
|
||||||
@ -4476,35 +4467,13 @@ async function saveAccount() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 2: commit. Only fires if a previous "Prüfen" already validated the
|
|
||||||
// EXACT same creds (label changes don't break this — label isn't part of the
|
|
||||||
// credential identity).
|
|
||||||
const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
|
const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
|
||||||
if (_validatedCreds &&
|
|
||||||
_validatedCreds.hosterName === ctx.hosterName &&
|
|
||||||
_validatedCreds.authType === ctx.authType &&
|
|
||||||
_validatedCreds.snapshot === snapshotKey) {
|
|
||||||
// Set busy INSIDE the try so a sync throw on the saveBtn deref above can't
|
|
||||||
// leak _accountModalBusy=true and lock the user out for the session.
|
|
||||||
try {
|
|
||||||
_accountModalBusy = 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 1: validate ephemerally. NOTHING is written to config.hosters here.
|
|
||||||
// Snapshot the session token so a stale late-arriving response from a
|
|
||||||
// closed-and-reopened modal can't stomp the new session's state.
|
|
||||||
const mySession = _accountModalSession;
|
const mySession = _accountModalSession;
|
||||||
_accountModalBusy = true;
|
_accountModalBusy = true;
|
||||||
|
_accountModalBusySession = mySession;
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
statusEl.textContent = 'Prüfe Login…';
|
saveBtn.textContent = ctx.isEdit ? 'Prüfe und speichere…' : 'Prüfe und lege an…';
|
||||||
|
statusEl.textContent = 'Prüfe Zugangsdaten…';
|
||||||
statusEl.className = 'account-modal-status checking';
|
statusEl.className = 'account-modal-status checking';
|
||||||
|
|
||||||
const otpInput = document.getElementById('accField_otp');
|
const otpInput = document.getElementById('accField_otp');
|
||||||
@ -4518,94 +4487,86 @@ async function saveAccount() {
|
|||||||
otp
|
otp
|
||||||
};
|
};
|
||||||
|
|
||||||
let row;
|
let result;
|
||||||
try {
|
try {
|
||||||
row = await window.api.validateCredentials(payload);
|
result = await window.AccountSubmit.submitValidatedAccount({
|
||||||
} catch (err) {
|
validate: () => window.api.validateCredentials(payload),
|
||||||
row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' };
|
commit: (validation) => _commitAccount(ctx, creds, validation),
|
||||||
} finally {
|
isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
|
||||||
if (mySession === _accountModalSession) {
|
});
|
||||||
_accountModalBusy = false;
|
} catch (error) {
|
||||||
if (saveBtn) saveBtn.disabled = false;
|
result = { status: 'error', error };
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stale response — modal was closed/reopened while we awaited. Drop it.
|
const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey);
|
||||||
if (mySession !== _accountModalSession) return;
|
if (result.status === 'committed' && current) {
|
||||||
|
const validation = result.validation || {};
|
||||||
if (row && row.status === 'otp_required') {
|
statusEl.textContent = validation.status === 'warn'
|
||||||
statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
|
? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.'
|
||||||
statusEl.className = 'account-modal-status error';
|
: validation.message || 'Account wurde erfolgreich geprüft und gespeichert.';
|
||||||
_showOtpField();
|
|
||||||
_wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
|
|
||||||
saveBtn.textContent = 'Mit OTP prüfen';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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();
|
||||||
_validatedCreds = {
|
saveBtn.textContent = ctx.isEdit ? 'Gespeichert' : 'Angelegt';
|
||||||
hosterName: ctx.hosterName,
|
saveBtn.disabled = true;
|
||||||
authType: ctx.authType,
|
if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
|
||||||
snapshot: snapshotKey,
|
_autoCloseTimer = setTimeout(() => {
|
||||||
status: row.status,
|
_autoCloseTimer = null;
|
||||||
message: row.message || ''
|
closeAccountModal();
|
||||||
};
|
}, 600);
|
||||||
saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// error
|
|
||||||
const msg = (row && row.message) || 'Login fehlgeschlagen';
|
_releaseAccountSubmit(mySession);
|
||||||
|
if (!current) return;
|
||||||
|
|
||||||
|
if (result.status === 'otp_required') {
|
||||||
|
const validation = result.validation || {};
|
||||||
|
statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.';
|
||||||
|
statusEl.className = 'account-modal-status error';
|
||||||
|
_showOtpField();
|
||||||
|
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = result.validation || {};
|
||||||
|
const msg = result.status === 'error'
|
||||||
|
? (result.error && result.error.message) || 'Prüfung oder Speichern fehlgeschlagen'
|
||||||
|
: validation.message || 'Login fehlgeschlagen';
|
||||||
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) {
|
function _copyHosterTree(hosters) {
|
||||||
// Persist the validated creds to config.hosters and close the modal. By the
|
const candidate = {};
|
||||||
// time we reach this function the validate-credentials IPC has already
|
for (const [name, accounts] of Object.entries(hosters || {})) {
|
||||||
// returned ok/warn for these exact creds, so we skip a redundant re-check.
|
candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts;
|
||||||
let accountId;
|
}
|
||||||
if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = [];
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _commitAccount(ctx, creds, validation) {
|
||||||
|
const candidateHosters = _copyHosterTree(config.hosters);
|
||||||
|
if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = [];
|
||||||
|
let accountId = ctx.accountId;
|
||||||
if (ctx.isEdit) {
|
if (ctx.isEdit) {
|
||||||
accountId = ctx.accountId;
|
const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId);
|
||||||
const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
|
if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.');
|
||||||
if (idx >= 0) {
|
candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[ctx.hosterName][idx], ...creds };
|
||||||
config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
|
|
||||||
} else {
|
|
||||||
_accountModalBusy = false;
|
|
||||||
const _sb = document.getElementById('saveAccountBtn'); if (_sb) _sb.disabled = false;
|
|
||||||
const _st = document.getElementById('accountModalStatus');
|
|
||||||
if (_st) {
|
|
||||||
_st.textContent = 'Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.';
|
|
||||||
_st.className = 'account-modal-status error';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
config.hosters[ctx.hosterName].push({ id: accountId, ...creds });
|
candidateHosters[ctx.hosterName].push({ id: accountId, ...creds });
|
||||||
}
|
}
|
||||||
await window.api.saveConfig({ hosters: config.hosters });
|
await window.api.saveConfig({ hosters: candidateHosters });
|
||||||
// Skip the redundant await getConfig() — the in-memory state is the source
|
config.hosters = candidateHosters;
|
||||||
// of truth for what we just wrote, decrypted creds didn't change, and the
|
accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
|
||||||
// round-trip was the main lag source on add/delete.
|
|
||||||
accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
|
|
||||||
ensureAccountStatusEntries();
|
ensureAccountStatusEntries();
|
||||||
syncSelectedUploadHosters();
|
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) {
|
if (ctx.isEdit) {
|
||||||
updateAccountCard(accountId);
|
updateAccountCard(accountId);
|
||||||
} else {
|
} else {
|
||||||
renderAccounts();
|
renderAccounts();
|
||||||
}
|
}
|
||||||
renderHosterSummary();
|
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() {
|
||||||
@ -5170,6 +5131,7 @@ function setupListeners() {
|
|||||||
|
|
||||||
// Account hoster select change → update credential fields
|
// Account hoster select change → update credential fields
|
||||||
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
|
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
|
||||||
|
_invalidateAccountSubmit();
|
||||||
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
|
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
|
||||||
const authType = opt ? opt.authType : 'login';
|
const authType = opt ? opt.authType : 'login';
|
||||||
const credsContainer = document.getElementById('accountCredsFields');
|
const credsContainer = document.getElementById('accountCredsFields');
|
||||||
@ -5180,15 +5142,6 @@ function setupListeners() {
|
|||||||
input.type = input.type === 'password' ? 'text' : 'password';
|
input.type = input.type === 'password' ? 'text' : 'password';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
document.getElementById('accountModalStatus').textContent = '';
|
|
||||||
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();
|
_wireCredFieldInvalidation();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -421,6 +421,7 @@
|
|||||||
<script src="../lib/throttled-cache.js"></script>
|
<script src="../lib/throttled-cache.js"></script>
|
||||||
<script src="../lib/coalesced-set.js"></script>
|
<script src="../lib/coalesced-set.js"></script>
|
||||||
<script src="../lib/throttle-timer.js"></script>
|
<script src="../lib/throttle-timer.js"></script>
|
||||||
|
<script src="account-submit.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -1,195 +1,128 @@
|
|||||||
// 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 { test } = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert/strict');
|
||||||
|
const { submitValidatedAccount } = require('../renderer/account-submit');
|
||||||
|
|
||||||
// ---- Re-implementations of the renderer's pure helpers ----
|
test('ok validates and commits exactly once in one submission', async () => {
|
||||||
// These mirror the production code exactly so the tests serve as both a guard
|
let validations = 0;
|
||||||
// and executable spec for what saveAccount() must do.
|
let commits = 0;
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
function credsSnapshotKey(authType, creds) {
|
validate: async () => {
|
||||||
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
|
validations++;
|
||||||
return `api:${creds.apiKey || ''}`;
|
return { status: 'ok', message: 'Login erfolgreich' };
|
||||||
}
|
|
||||||
|
|
||||||
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 })
|
commit: async () => {
|
||||||
|
commits++;
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
});
|
});
|
||||||
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
|
||||||
const creds = { username: 'u', password: 'p' };
|
assert.equal(result.status, 'committed');
|
||||||
await sm.click(ctx, creds, ''); // first click → otp_required
|
assert.equal(validations, 1);
|
||||||
await sm.click(ctx, creds, '123456'); // retry with otp → ok
|
assert.equal(commits, 1);
|
||||||
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 () => {
|
test('warn validates and commits exactly once in one submission', async () => {
|
||||||
const persistCalls = [];
|
let commits = 0;
|
||||||
let validateCount = 0;
|
const validation = { status: 'warn', message: 'Login mit Warnung' };
|
||||||
const sm = makeStateMachine({
|
const result = await submitValidatedAccount({
|
||||||
validateImpl: async () => { validateCount++; return { status: 'ok' }; },
|
validate: async () => validation,
|
||||||
persistImpl: async (ctx, creds) => persistCalls.push({ ctx, creds })
|
commit: async (received) => {
|
||||||
|
commits++;
|
||||||
|
assert.equal(received, validation);
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
});
|
});
|
||||||
const ctx = { hosterName: 'doodstream.com', authType: 'login', isEdit: false };
|
|
||||||
await sm.click(ctx, { username: 'u', password: 'p' }); // validate → green
|
assert.equal(result.status, 'committed');
|
||||||
sm.editField(); // user edits cred field → snapshot dropped
|
assert.equal(result.validation, validation);
|
||||||
await sm.click(ctx, { username: 'u', password: 'newpw' }); // creds differ → re-validate
|
assert.equal(commits, 1);
|
||||||
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)', () => {
|
for (const status of ['error', 'skipped']) {
|
||||||
// Label changes must NOT invalidate validation — label is metadata, not a credential.
|
test(`${status} rejects without committing`, async () => {
|
||||||
assert.equal(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
let commits = 0;
|
||||||
credsSnapshotKey('login', { username: 'u', password: 'p', label: 'XYZ' }));
|
const validation = { status, message: `${status} result` };
|
||||||
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
const result = await submitValidatedAccount({
|
||||||
credsSnapshotKey('login', { username: 'u', password: 'P' })); // password char-case
|
validate: async () => validation,
|
||||||
assert.notEqual(credsSnapshotKey('login', { username: 'u', password: 'p' }),
|
commit: async () => {
|
||||||
credsSnapshotKey('login', { username: 'U', password: 'p' })); // username diff
|
commits++;
|
||||||
assert.equal(credsSnapshotKey('api', { apiKey: 'KEY' }),
|
},
|
||||||
credsSnapshotKey('api', { apiKey: 'KEY', label: 'mein key' }));
|
isCurrent: () => true
|
||||||
assert.notEqual(credsSnapshotKey('api', { apiKey: 'KEY' }),
|
});
|
||||||
credsSnapshotKey('api', { apiKey: 'KEY2' }));
|
|
||||||
|
assert.equal(result.status, 'rejected');
|
||||||
|
assert.equal(result.validation, validation);
|
||||||
|
assert.equal(commits, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('validate throw returns error without committing', async () => {
|
||||||
|
const expected = new Error('validation failed');
|
||||||
|
let commits = 0;
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => {
|
||||||
|
throw expected;
|
||||||
|
},
|
||||||
|
commit: async () => {
|
||||||
|
commits++;
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.equal(result.error, expected);
|
||||||
|
assert.equal(commits, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ephemeral hosterConfig shape matches what per-hoster checkers expect', () => {
|
test('otp_required returns challenge without committing', async () => {
|
||||||
// The per-hoster checkers in main.js read .username/.password/.apiKey directly.
|
let commits = 0;
|
||||||
// This guards the validate-credentials IPC contract from drifting.
|
const validation = { status: 'otp_required', message: 'OTP gesendet' };
|
||||||
const cfg = buildEphemeralHosterConfig({ hoster: 'doodstream.com', username: 'u', password: 'p' });
|
const result = await submitValidatedAccount({
|
||||||
assert.equal(cfg.username, 'u');
|
validate: async () => validation,
|
||||||
assert.equal(cfg.password, 'p');
|
commit: async () => {
|
||||||
assert.equal(cfg.apiKey, '');
|
commits++;
|
||||||
assert.equal(cfg.enabled, true);
|
},
|
||||||
const cfg2 = buildEphemeralHosterConfig({ hoster: 'byse.sx', apiKey: 'K' });
|
isCurrent: () => true
|
||||||
assert.equal(cfg2.apiKey, 'K');
|
});
|
||||||
assert.equal(cfg2.username, '');
|
|
||||||
|
assert.equal(result.status, 'otp_required');
|
||||||
|
assert.equal(result.validation, validation);
|
||||||
|
assert.equal(commits, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale submission is rejected immediately before commit', async () => {
|
||||||
|
let current = true;
|
||||||
|
let commits = 0;
|
||||||
|
const validation = { status: 'ok' };
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => {
|
||||||
|
current = false;
|
||||||
|
return validation;
|
||||||
|
},
|
||||||
|
commit: async () => {
|
||||||
|
commits++;
|
||||||
|
},
|
||||||
|
isCurrent: () => current
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'stale');
|
||||||
|
assert.equal(result.validation, validation);
|
||||||
|
assert.equal(commits, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save failure returns error after one commit attempt', async () => {
|
||||||
|
const expected = new Error('save failed');
|
||||||
|
let commits = 0;
|
||||||
|
const result = await submitValidatedAccount({
|
||||||
|
validate: async () => ({ status: 'ok' }),
|
||||||
|
commit: async () => {
|
||||||
|
commits++;
|
||||||
|
throw expected;
|
||||||
|
},
|
||||||
|
isCurrent: () => true
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.equal(result.error, expected);
|
||||||
|
assert.equal(commits, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user