diff --git a/renderer/account-submit.js b/renderer/account-submit.js index cb5fb1a..3415e3a 100644 --- a/renderer/account-submit.js +++ b/renderer/account-submit.js @@ -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); diff --git a/renderer/app.js b/renderer/app.js index 5ca1760..068bb99 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -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 => `` ).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(); diff --git a/renderer/index.html b/renderer/index.html index 84a07a9..45f6e26 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -266,7 +266,7 @@
diff --git a/tests/validate-credentials.test.js b/tests/validate-credentials.test.js index b54e245..c595821 100644 --- a/tests/validate-credentials.test.js +++ b/tests/validate-credentials.test.js @@ -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;