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) {
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;
try {
validation = await validate();
@ -7,7 +11,11 @@
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') {
return { status: 'otp_required', validation };
}
@ -15,15 +23,51 @@
return { status: 'rejected', validation };
}
let value;
try {
await commit(validation);
return { status: 'committed', validation };
value = await commit(validation);
} catch (error) {
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 (scope) scope.AccountSubmit = accountSubmit;
})(typeof window !== 'undefined' ? window : globalThis);

View File

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

View File

@ -266,7 +266,7 @@
</div>
<div class="modal-footer">
<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>

View File

@ -1,6 +1,86 @@
const { test } = require('node:test');
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 () => {
let validations = 0;