fix(accounts): preserve single-flight save semantics

Keep the account submit lock independent of modal sessions, use the exact button labels, and separate persistence from renderer application so a post-save UI failure cannot invite a duplicate save.
This commit is contained in:
Administrator 2026-08-07 17:47:40 +02:00
parent 0652edf69f
commit 95bff2581b
4 changed files with 173 additions and 49 deletions

View File

@ -1,5 +1,9 @@
(function (scope) { (function (scope) {
async function submitValidatedAccount({ validate, commit, isCurrent }) { function getAccountSubmitLabel({ isEdit } = {}) {
return isEdit ? 'Prüfen und speichern' : 'Prüfen und anlegen';
}
async function submitValidatedAccount({ validate, commit, afterCommit, isCurrent }) {
let validation; let validation;
try { try {
validation = await validate(); validation = await validate();
@ -7,7 +11,11 @@
return { status: 'error', error }; return { status: 'error', error };
} }
if (!isCurrent()) return { status: 'stale', validation }; try {
if (!isCurrent()) return { status: 'stale', validation };
} catch (error) {
return { status: 'error', error, validation };
}
if (validation && validation.status === 'otp_required') { if (validation && validation.status === 'otp_required') {
return { status: 'otp_required', validation }; return { status: 'otp_required', validation };
} }
@ -15,15 +23,51 @@
return { status: 'rejected', validation }; return { status: 'rejected', validation };
} }
let value;
try { try {
await commit(validation); value = await commit(validation);
return { status: 'committed', validation };
} catch (error) { } catch (error) {
return { status: 'error', error, validation }; return { status: 'error', error, validation };
} }
let postCommitError;
if (typeof afterCommit === 'function') {
try {
await afterCommit(value, validation);
} catch (error) {
postCommitError = error;
}
}
const committedResult = { status: 'committed', committed: true, validation, value };
if (postCommitError) committedResult.postCommitError = postCommitError;
try {
if (!isCurrent()) return { ...committedResult, status: 'stale' };
} catch {
return { ...committedResult, status: 'stale' };
}
return committedResult;
} }
const accountSubmit = { submitValidatedAccount }; function createAccountSubmitter() {
let pending = null;
return {
isBusy() {
return pending !== null;
},
submit(options) {
if (pending) return null;
const operation = submitValidatedAccount(options);
const tracked = operation.finally(() => {
if (pending === tracked) pending = null;
});
pending = tracked;
return tracked;
}
};
}
const accountSubmit = { createAccountSubmitter, getAccountSubmitLabel, submitValidatedAccount };
if (typeof module !== 'undefined' && module.exports) module.exports = accountSubmit; if (typeof module !== 'undefined' && module.exports) module.exports = accountSubmit;
if (scope) scope.AccountSubmit = accountSubmit; if (scope) scope.AccountSubmit = accountSubmit;
})(typeof window !== 'undefined' ? window : globalThis); })(typeof window !== 'undefined' ? window : globalThis);

View File

@ -4281,7 +4281,7 @@ function openAccountModal(editAccountId) {
title.textContent = 'Account bearbeiten'; title.textContent = 'Account bearbeiten';
subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten und prüfen.`; 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 & speichern'; saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: true });
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 {
@ -4289,7 +4289,7 @@ function openAccountModal(editAccountId) {
title.textContent = 'Account hinzufügen'; title.textContent = 'Account hinzufügen';
subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Der Account wird vor dem Anlegen geprüft.'; 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 = 'Anlegen & prüfen'; saveBtn.textContent = window.AccountSubmit.getAccountSubmitLabel({ isEdit: false });
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('');
@ -4371,18 +4371,16 @@ function readAccountCredsFromModal(authType) {
return { enabled: !!apiKey, authType: 'api', apiKey, label }; return { enabled: !!apiKey, authType: 'api', apiKey, label };
} }
let _accountModalBusy = false; const _accountSubmitter = window.AccountSubmit.createAccountSubmitter();
let _accountModalBusySession = null; let _accountModalCommitLocked = false;
let _autoCloseTimer = null; let _autoCloseTimer = null;
let _accountModalSession = 0; let _accountModalSession = 0;
function _resetAccountModalState() { function _resetAccountModalState() {
_accountModalSession++; _accountModalSession++;
_accountModalBusy = false; _accountModalCommitLocked = false;
_accountModalBusySession = null;
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; } if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
const saveBtn = document.getElementById('saveAccountBtn'); _syncAccountSubmitButton();
if (saveBtn) saveBtn.disabled = false;
} }
function _credsSnapshotKey(authType, creds) { function _credsSnapshotKey(authType, creds) {
@ -4390,9 +4388,15 @@ function _credsSnapshotKey(authType, creds) {
return `api:${creds.apiKey || ''}`; return `api:${creds.apiKey || ''}`;
} }
function _defaultAccountSubmitButtonText(ctx, hasOtp) { function _defaultAccountSubmitButtonText(ctx) {
if (hasOtp) return ctx && ctx.isEdit ? 'Mit OTP prüfen & speichern' : 'Mit OTP anlegen & prüfen'; return window.AccountSubmit.getAccountSubmitLabel({ isEdit: !!(ctx && ctx.isEdit) });
return ctx && ctx.isEdit ? 'Prüfen & speichern' : 'Anlegen & prüfen'; }
function _syncAccountSubmitButton() {
const saveBtn = document.getElementById('saveAccountBtn');
if (!saveBtn) return;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
saveBtn.disabled = _accountSubmitter.isBusy() || _accountModalCommitLocked;
} }
function _invalidateAccountSubmit() { function _invalidateAccountSubmit() {
@ -4403,20 +4407,9 @@ function _invalidateAccountSubmit() {
statusEl.className = 'account-modal-status'; statusEl.className = 'account-modal-status';
} }
const saveBtn = document.getElementById('saveAccountBtn'); const saveBtn = document.getElementById('saveAccountBtn');
if (saveBtn && !_accountModalBusy) { if (saveBtn && !_accountSubmitter.isBusy() && !_accountModalCommitLocked) {
saveBtn.disabled = false; saveBtn.disabled = false;
saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext(), !!document.getElementById('accField_otp')); saveBtn.textContent = _defaultAccountSubmitButtonText(_determineHosterContext());
}
}
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'));
} }
} }
@ -4454,7 +4447,7 @@ function _isAccountSubmitCurrent(session, ctx, snapshotKey) {
} }
async function saveAccount() { async function saveAccount() {
if (_accountModalBusy) return; if (_accountSubmitter.isBusy() || _accountModalCommitLocked) return;
const ctx = _determineHosterContext(); const ctx = _determineHosterContext();
if (!ctx) return; if (!ctx) return;
@ -4469,13 +4462,6 @@ async function saveAccount() {
const snapshotKey = _credsSnapshotKey(ctx.authType, creds); const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
const mySession = _accountModalSession; const mySession = _accountModalSession;
_accountModalBusy = true;
_accountModalBusySession = mySession;
saveBtn.disabled = true;
saveBtn.textContent = ctx.isEdit ? 'Prüfe und speichere…' : 'Prüfe und lege an…';
statusEl.textContent = 'Prüfe Zugangsdaten…';
statusEl.className = 'account-modal-status checking';
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 = { const payload = {
@ -4487,26 +4473,35 @@ async function saveAccount() {
otp otp
}; };
const submission = _accountSubmitter.submit({
validate: () => window.api.validateCredentials(payload),
commit: () => _persistAccount(ctx, creds),
afterCommit: (persisted, validation) => _applyCommittedAccount(persisted, validation),
isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
});
if (!submission) return;
saveBtn.disabled = true;
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
statusEl.textContent = 'Prüfe Zugangsdaten…';
statusEl.className = 'account-modal-status checking';
let result; let result;
try { try {
result = await window.AccountSubmit.submitValidatedAccount({ result = await submission;
validate: () => window.api.validateCredentials(payload),
commit: (validation) => _commitAccount(ctx, creds, validation),
isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
});
} catch (error) { } catch (error) {
result = { status: 'error', error }; result = { status: 'error', error };
} }
const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey); const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey);
if (result.status === 'committed' && current) { if (result.status === 'committed' && current) {
_accountModalCommitLocked = true;
const validation = result.validation || {}; const validation = result.validation || {};
statusEl.textContent = validation.status === 'warn' statusEl.textContent = validation.status === 'warn'
? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.' ? validation.message || 'Account wurde mit Warnung geprüft und gespeichert.'
: validation.message || 'Account wurde erfolgreich geprüft und gespeichert.'; : validation.message || 'Account wurde erfolgreich geprüft und gespeichert.';
statusEl.className = 'account-modal-status ok'; statusEl.className = 'account-modal-status ok';
_hideOtpField(); _hideOtpField();
saveBtn.textContent = ctx.isEdit ? 'Gespeichert' : 'Angelegt'; saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
saveBtn.disabled = true; saveBtn.disabled = true;
if (_autoCloseTimer) clearTimeout(_autoCloseTimer); if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
_autoCloseTimer = setTimeout(() => { _autoCloseTimer = setTimeout(() => {
@ -4516,7 +4511,7 @@ async function saveAccount() {
return; return;
} }
_releaseAccountSubmit(mySession); _syncAccountSubmitButton();
if (!current) return; if (!current) return;
if (result.status === 'otp_required') { if (result.status === 'otp_required') {
@ -4524,7 +4519,7 @@ async function saveAccount() {
statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.'; statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.';
statusEl.className = 'account-modal-status error'; statusEl.className = 'account-modal-status error';
_showOtpField(); _showOtpField();
saveBtn.textContent = _defaultAccountSubmitButtonText(ctx, true); saveBtn.textContent = _defaultAccountSubmitButtonText(ctx);
return; return;
} }
@ -4544,7 +4539,7 @@ function _copyHosterTree(hosters) {
return candidate; return candidate;
} }
async function _commitAccount(ctx, creds, validation) { async function _persistAccount(ctx, creds) {
const candidateHosters = _copyHosterTree(config.hosters); const candidateHosters = _copyHosterTree(config.hosters);
if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = []; if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = [];
let accountId = ctx.accountId; let accountId = ctx.accountId;
@ -4557,11 +4552,16 @@ async function _commitAccount(ctx, creds, validation) {
candidateHosters[ctx.hosterName].push({ id: accountId, ...creds }); candidateHosters[ctx.hosterName].push({ id: accountId, ...creds });
} }
await window.api.saveConfig({ hosters: candidateHosters }); await window.api.saveConfig({ hosters: candidateHosters });
return { accountId, candidateHosters, isEdit: ctx.isEdit };
}
function _applyCommittedAccount(persisted, validation) {
const { accountId, candidateHosters, isEdit } = persisted;
config.hosters = candidateHosters; config.hosters = candidateHosters;
accountStatuses[accountId] = { status: validation.status, message: validation.message || '' }; accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters(); syncSelectedUploadHosters();
if (ctx.isEdit) { if (isEdit) {
updateAccountCard(accountId); updateAccountCard(accountId);
} else { } else {
renderAccounts(); renderAccounts();

View File

@ -266,7 +266,7 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button> <button class="btn btn-secondary" id="cancelAccountModalBtn">Abbrechen</button>
<button class="btn btn-primary" id="saveAccountBtn">Anlegen &amp; prüfen</button> <button class="btn btn-primary" id="saveAccountBtn">Prüfen und anlegen</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,6 +1,86 @@
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { submitValidatedAccount } = require('../renderer/account-submit'); const {
createAccountSubmitter,
getAccountSubmitLabel,
submitValidatedAccount
} = require('../renderer/account-submit');
test('account submit labels stay exact for add, edit, and OTP retries', () => {
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: false }), 'Prüfen und anlegen');
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: false }), 'Prüfen und speichern');
assert.equal(getAccountSubmitLabel({ isEdit: false, hasOtp: true }), 'Prüfen und anlegen');
assert.equal(getAccountSubmitLabel({ isEdit: true, hasOtp: true }), 'Prüfen und speichern');
});
test('close and reopen cannot start a second save while the first save is pending', async () => {
const submitter = createAccountSubmitter();
let current = true;
let commits = 0;
let applies = 0;
let saveStarted;
let finishSave;
const started = new Promise(resolve => { saveStarted = resolve; });
const saving = new Promise(resolve => { finishSave = resolve; });
const first = submitter.submit({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
saveStarted();
await saving;
return { accountId: 'first' };
},
afterCommit: async () => {
applies++;
},
isCurrent: () => current
});
await started;
current = false;
const second = submitter.submit({
validate: async () => ({ status: 'ok' }),
commit: async () => {
commits++;
},
isCurrent: () => true
});
assert.equal(second, null);
assert.equal(submitter.isBusy(), true);
finishSave();
const result = await first;
assert.equal(result.status, 'stale');
assert.equal(result.committed, true);
assert.equal(commits, 1);
assert.equal(applies, 1);
assert.equal(submitter.isBusy(), false);
});
test('post-save apply failure remains committed and cannot invite a duplicate retry', async () => {
const expected = new Error('render failed');
let saves = 0;
let applies = 0;
const result = await submitValidatedAccount({
validate: async () => ({ status: 'ok' }),
commit: async () => {
saves++;
return { accountId: 'saved-account' };
},
afterCommit: async () => {
applies++;
throw expected;
},
isCurrent: () => true
});
assert.equal(result.status, 'committed');
assert.equal(result.value.accountId, 'saved-account');
assert.equal(result.postCommitError, expected);
assert.equal(saves, 1);
assert.equal(applies, 1);
});
test('ok validates and commits exactly once in one submission', async () => { test('ok validates and commits exactly once in one submission', async () => {
let validations = 0; let validations = 0;