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:
parent
0652edf69f
commit
95bff2581b
@ -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 };
|
||||
}
|
||||
|
||||
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 accountSubmit = { submitValidatedAccount };
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await window.AccountSubmit.submitValidatedAccount({
|
||||
const submission = _accountSubmitter.submit({
|
||||
validate: () => window.api.validateCredentials(payload),
|
||||
commit: (validation) => _commitAccount(ctx, creds, validation),
|
||||
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 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();
|
||||
|
||||
@ -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 & prüfen</button>
|
||||
<button class="btn btn-primary" id="saveAccountBtn">Prüfen und anlegen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user