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) {
|
(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);
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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 & prüfen</button>
|
<button class="btn btn-primary" id="saveAccountBtn">Prüfen und anlegen</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user