diff --git a/renderer/account-submit.js b/renderer/account-submit.js
new file mode 100644
index 0000000..cb5fb1a
--- /dev/null
+++ b/renderer/account-submit.js
@@ -0,0 +1,29 @@
+(function (scope) {
+ async function submitValidatedAccount({ validate, commit, isCurrent }) {
+ let validation;
+ try {
+ validation = await validate();
+ } catch (error) {
+ return { status: 'error', error };
+ }
+
+ if (!isCurrent()) return { status: 'stale', validation };
+ if (validation && validation.status === 'otp_required') {
+ return { status: 'otp_required', validation };
+ }
+ if (!validation || (validation.status !== 'ok' && validation.status !== 'warn')) {
+ return { status: 'rejected', validation };
+ }
+
+ try {
+ await commit(validation);
+ return { status: 'committed', validation };
+ } catch (error) {
+ return { status: 'error', error, validation };
+ }
+ }
+
+ const accountSubmit = { 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 62b9b59..5ca1760 100644
--- a/renderer/app.js
+++ b/renderer/app.js
@@ -4260,8 +4260,6 @@ function getCredsFieldsHtml(authType, account, hoster) {
function openAccountModal(editAccountId) {
editingAccountId = editAccountId || null;
- // Reset the two-step state — any previously validated snapshot from a prior
- // modal session is stale and must not allow a no-recheck commit.
_resetAccountModalState();
const modal = document.getElementById('accountModal');
const title = document.getElementById('accountModalTitle');
@@ -4281,17 +4279,17 @@ function openAccountModal(editAccountId) {
const found = findAccountById(editingAccountId);
if (!found) return;
title.textContent = 'Account bearbeiten';
- subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten.`;
+ subtitle.textContent = `Zugangsdaten für ${getAccountDisplayName(found.name, found.account)} bearbeiten und prüfen.`;
hosterRow.style.display = 'none';
- saveBtn.textContent = 'Prüfen';
+ saveBtn.textContent = 'Prüfen & speichern';
if (labelInput) labelInput.value = found.account.label || '';
credsContainer.innerHTML = getCredsFieldsHtml(found.account.authType || 'login', found.account, found.name);
} else {
// Add mode — always show all options (multiple accounts per hoster allowed)
title.textContent = 'Account hinzufügen';
- subtitle.textContent = 'Wähle einen Hoster und gib deine Zugangsdaten ein. Erst „Prüfen" klicken; nach grünem Login wird daraus „Anlegen".';
+ 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 = 'Prüfen';
+ saveBtn.textContent = 'Anlegen & prüfen';
hosterSelect.innerHTML = HOSTER_ADD_OPTIONS.map(opt =>
``
).join('');
@@ -4308,10 +4306,6 @@ function openAccountModal(editAccountId) {
});
});
- // Wire field invalidation: any change to a cred field after a green check
- // drops the validated snapshot so the next click is a re-check, not a commit
- // of unverified creds. Re-wired here every open because credsContainer's HTML
- // was replaced.
_wireCredFieldInvalidation();
modal.style.display = 'flex';
@@ -4321,11 +4315,7 @@ function closeAccountModal() {
document.getElementById('accountModal').style.display = 'none';
_hideOtpField();
editingAccountId = null;
- // Cancel any pending auto-close so a stale timer can't close a future modal
- // the user reopens within the auto-close window.
- if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
- _validatedCreds = null;
- _accountModalBusy = false;
+ _resetAccountModalState();
}
function openDeleteAccountModal(accountId) {
@@ -4381,66 +4371,61 @@ function readAccountCredsFromModal(authType) {
return { enabled: !!apiKey, authType: 'api', apiKey, label };
}
-// --- Two-step account-modal state machine ---
-//
-// Goal: never persist invalid/unverified credentials to config.hosters. The
-// user clicks "Prüfen" → ephemeral validate-credentials IPC runs → on green
-// the button label flips to "Anlegen" / "Speichern" → the next click commits
-// to config. Editing any cred field between the two clicks drops the validated
-// snapshot so the user can't sneak unverified creds through by editing
-// post-green.
-//
-// Invariants enforced here:
-// 1. Nothing reaches config.hosters until _validatedCreds matches a green
-// result for the currently-typed creds.
-// 2. _accountModalBusy is set SYNCHRONOUSLY at the top of the click handler
-// before any await — guards against double-clicks producing duplicates.
-// 3. OTP retry stays ephemeral: each retry re-runs validate-credentials with
-// the new OTP, no config writes until green.
-// 4. Edit mode hits the same path → bad edits never overwrite known-good
-// creds on disk.
let _accountModalBusy = false;
-let _validatedCreds = null; // { hosterName, authType, snapshot, status } when green
+let _accountModalBusySession = null;
let _autoCloseTimer = null;
-// Session token used to ignore stale validate-credentials responses: if the
-// user closes the modal mid-flight and reopens it, the late .then must NOT
-// stomp the new session's state. Bumped on every modal reset.
let _accountModalSession = 0;
function _resetAccountModalState() {
- _accountModalBusy = false;
- _validatedCreds = null;
_accountModalSession++;
+ _accountModalBusy = false;
+ _accountModalBusySession = null;
if (_autoCloseTimer) { clearTimeout(_autoCloseTimer); _autoCloseTimer = null; }
+ const saveBtn = document.getElementById('saveAccountBtn');
+ if (saveBtn) saveBtn.disabled = false;
}
function _credsSnapshotKey(authType, creds) {
- // Identity key for the typed creds — used to detect post-validation edits.
- // Label changes do NOT invalidate (label is metadata, not a credential).
if (authType === 'login') return `login:${creds.username || ''}:${creds.password || ''}`;
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 _invalidateAccountSubmit() {
+ _accountModalSession++;
+ const statusEl = document.getElementById('accountModalStatus');
+ if (statusEl) {
+ statusEl.textContent = '';
+ statusEl.className = 'account-modal-status';
+ }
+ const saveBtn = document.getElementById('saveAccountBtn');
+ if (saveBtn && !_accountModalBusy) {
+ 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'));
+ }
+}
+
function _wireCredFieldInvalidation() {
- // Any change to a cred IDENTITY field (username/password/apiKey) clears the
- // validated snapshot and reverts the button to "Prüfen". Label edits don't
- // invalidate (label is metadata, not a credential). OTP edits don't either:
- // OTP is an ephemeral auth challenge — once doodstream returned "ok" for
- // these username+password+OTP, the resulting trust is on the creds; the user
- // clearing or fixing the OTP field afterward shouldn't force a re-prompt.
const ids = ['accField_username', 'accField_password', 'accField_apiKey'];
for (const id of ids) {
const el = document.getElementById(id);
if (!el || el.dataset.invalidateBound === '1') continue;
- el.addEventListener('input', () => {
- if (_validatedCreds) {
- _validatedCreds = null;
- const saveBtn = document.getElementById('saveAccountBtn');
- if (saveBtn) saveBtn.textContent = 'Prüfen';
- const statusEl = document.getElementById('accountModalStatus');
- if (statusEl) { statusEl.textContent = ''; statusEl.className = 'account-modal-status'; }
- }
- });
+ el.addEventListener('input', _invalidateAccountSubmit);
el.dataset.invalidateBound = '1';
}
}
@@ -4458,11 +4443,17 @@ function _determineHosterContext() {
return { hosterName: opt.hoster, authType: opt.authType, accountId: null, isEdit: false };
}
+function _isAccountSubmitCurrent(session, ctx, snapshotKey) {
+ if (session !== _accountModalSession) return false;
+ const currentCtx = _determineHosterContext();
+ if (!currentCtx) return false;
+ if (currentCtx.hosterName !== ctx.hosterName || currentCtx.authType !== ctx.authType) return false;
+ if (currentCtx.accountId !== ctx.accountId || currentCtx.isEdit !== ctx.isEdit) return false;
+ const currentCreds = readAccountCredsFromModal(currentCtx.authType);
+ return _credsSnapshotKey(currentCtx.authType, currentCreds) === snapshotKey;
+}
+
async function saveAccount() {
- // SYNCHRONOUS re-entry guard — must come before any await. Without this a
- // double-click before the first IPC returns triggers two saveAccount() calls
- // and (in the old code) two pushes/two IPCs. _accountModalBusy is checked
- // synchronously and set synchronously, so the second click no-ops cleanly.
if (_accountModalBusy) return;
const ctx = _determineHosterContext();
@@ -4476,35 +4467,13 @@ async function saveAccount() {
return;
}
- // STEP 2: commit. Only fires if a previous "Prüfen" already validated the
- // EXACT same creds (label changes don't break this — label isn't part of the
- // credential identity).
const snapshotKey = _credsSnapshotKey(ctx.authType, creds);
- if (_validatedCreds &&
- _validatedCreds.hosterName === ctx.hosterName &&
- _validatedCreds.authType === ctx.authType &&
- _validatedCreds.snapshot === snapshotKey) {
- // Set busy INSIDE the try so a sync throw on the saveBtn deref above can't
- // leak _accountModalBusy=true and lock the user out for the session.
- try {
- _accountModalBusy = true;
- saveBtn.disabled = true;
- saveBtn.textContent = ctx.isEdit ? 'Speichere…' : 'Lege an…';
- await _commitAccount(ctx, creds, _validatedCreds.status, _validatedCreds.message);
- } finally {
- _accountModalBusy = false;
- if (saveBtn) saveBtn.disabled = false;
- }
- return;
- }
-
- // STEP 1: validate ephemerally. NOTHING is written to config.hosters here.
- // Snapshot the session token so a stale late-arriving response from a
- // closed-and-reopened modal can't stomp the new session's state.
const mySession = _accountModalSession;
_accountModalBusy = true;
+ _accountModalBusySession = mySession;
saveBtn.disabled = true;
- statusEl.textContent = 'Prüfe Login…';
+ 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');
@@ -4518,94 +4487,86 @@ async function saveAccount() {
otp
};
- let row;
+ let result;
try {
- row = await window.api.validateCredentials(payload);
- } catch (err) {
- row = { status: 'error', message: err && err.message ? err.message : 'Prüfung fehlgeschlagen' };
- } finally {
- if (mySession === _accountModalSession) {
- _accountModalBusy = false;
- if (saveBtn) saveBtn.disabled = false;
- }
+ result = await window.AccountSubmit.submitValidatedAccount({
+ validate: () => window.api.validateCredentials(payload),
+ commit: (validation) => _commitAccount(ctx, creds, validation),
+ isCurrent: () => _isAccountSubmitCurrent(mySession, ctx, snapshotKey)
+ });
+ } catch (error) {
+ result = { status: 'error', error };
}
- // Stale response — modal was closed/reopened while we awaited. Drop it.
- if (mySession !== _accountModalSession) return;
-
- if (row && row.status === 'otp_required') {
- statusEl.textContent = row.message || 'OTP wurde an deine E-Mail gesendet.';
- statusEl.className = 'account-modal-status error';
- _showOtpField();
- _wireCredFieldInvalidation(); // OTP input now exists — wire its listener too
- saveBtn.textContent = 'Mit OTP prüfen';
- return;
- }
- if (row && (row.status === 'ok' || row.status === 'warn')) {
- statusEl.textContent = row.status === 'warn' ? row.message || 'Prüfung mit Warnung abgeschlossen.' : 'Login erfolgreich! Klick „' + (ctx.isEdit ? 'Speichern' : 'Anlegen') + '" zum Übernehmen.';
+ const current = _isAccountSubmitCurrent(mySession, ctx, snapshotKey);
+ if (result.status === 'committed' && current) {
+ 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();
- _validatedCreds = {
- hosterName: ctx.hosterName,
- authType: ctx.authType,
- snapshot: snapshotKey,
- status: row.status,
- message: row.message || ''
- };
- saveBtn.textContent = ctx.isEdit ? 'Speichern' : 'Anlegen';
+ saveBtn.textContent = ctx.isEdit ? 'Gespeichert' : 'Angelegt';
+ saveBtn.disabled = true;
+ if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
+ _autoCloseTimer = setTimeout(() => {
+ _autoCloseTimer = null;
+ closeAccountModal();
+ }, 600);
return;
}
- // error
- const msg = (row && row.message) || 'Login fehlgeschlagen';
+
+ _releaseAccountSubmit(mySession);
+ if (!current) return;
+
+ if (result.status === 'otp_required') {
+ const validation = result.validation || {};
+ statusEl.textContent = validation.message || 'OTP wurde an deine E-Mail gesendet.';
+ statusEl.className = 'account-modal-status error';
+ _showOtpField();
+ saveBtn.textContent = _defaultAccountSubmitButtonText(ctx, true);
+ return;
+ }
+
+ const validation = result.validation || {};
+ const msg = result.status === 'error'
+ ? (result.error && result.error.message) || 'Prüfung oder Speichern fehlgeschlagen'
+ : validation.message || 'Login fehlgeschlagen';
statusEl.textContent = msg;
statusEl.className = 'account-modal-status error';
}
-async function _commitAccount(ctx, creds, validatedStatus, validatedMessage) {
- // Persist the validated creds to config.hosters and close the modal. By the
- // time we reach this function the validate-credentials IPC has already
- // returned ok/warn for these exact creds, so we skip a redundant re-check.
- let accountId;
- if (!Array.isArray(config.hosters[ctx.hosterName])) config.hosters[ctx.hosterName] = [];
+function _copyHosterTree(hosters) {
+ const candidate = {};
+ for (const [name, accounts] of Object.entries(hosters || {})) {
+ candidate[name] = Array.isArray(accounts) ? accounts.map(account => ({ ...account })) : accounts;
+ }
+ return candidate;
+}
+
+async function _commitAccount(ctx, creds, validation) {
+ const candidateHosters = _copyHosterTree(config.hosters);
+ if (!Array.isArray(candidateHosters[ctx.hosterName])) candidateHosters[ctx.hosterName] = [];
+ let accountId = ctx.accountId;
if (ctx.isEdit) {
- accountId = ctx.accountId;
- const idx = config.hosters[ctx.hosterName].findIndex(a => a.id === accountId);
- if (idx >= 0) {
- config.hosters[ctx.hosterName][idx] = { ...config.hosters[ctx.hosterName][idx], ...creds };
- } else {
- _accountModalBusy = false;
- const _sb = document.getElementById('saveAccountBtn'); if (_sb) _sb.disabled = false;
- const _st = document.getElementById('accountModalStatus');
- if (_st) {
- _st.textContent = 'Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.';
- _st.className = 'account-modal-status error';
- }
- return;
- }
+ const idx = candidateHosters[ctx.hosterName].findIndex(account => account.id === accountId);
+ if (idx < 0) throw new Error('Account nicht mehr in der Config — wurde extern gelöscht. Modal schließen und neu anlegen.');
+ candidateHosters[ctx.hosterName][idx] = { ...candidateHosters[ctx.hosterName][idx], ...creds };
} else {
accountId = `${ctx.hosterName}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
- config.hosters[ctx.hosterName].push({ id: accountId, ...creds });
+ candidateHosters[ctx.hosterName].push({ id: accountId, ...creds });
}
- await window.api.saveConfig({ hosters: config.hosters });
- // Skip the redundant await getConfig() — the in-memory state is the source
- // of truth for what we just wrote, decrypted creds didn't change, and the
- // round-trip was the main lag source on add/delete.
- accountStatuses[accountId] = { status: validatedStatus, message: validatedMessage || '' };
+ await window.api.saveConfig({ hosters: candidateHosters });
+ config.hosters = candidateHosters;
+ accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
ensureAccountStatusEntries();
syncSelectedUploadHosters();
- // Targeted updates instead of the 4-panel cascade. For add we need a full
- // accounts-list re-render (new card) and the hoster summary count; for edit
- // we can update the single card. Settings panel only needs re-render if its
- // hoster-summary section is visible — that's covered by renderHosterSummary.
if (ctx.isEdit) {
updateAccountCard(accountId);
} else {
renderAccounts();
}
renderHosterSummary();
- // Auto-close after a short pause so the user sees the success state.
- if (_autoCloseTimer) clearTimeout(_autoCloseTimer);
- _autoCloseTimer = setTimeout(() => { closeAccountModal(); _autoCloseTimer = null; }, 600);
}
function _showOtpField() {
@@ -5170,6 +5131,7 @@ function setupListeners() {
// Account hoster select change → update credential fields
document.getElementById('accountHosterSelect').addEventListener('change', (e) => {
+ _invalidateAccountSubmit();
const opt = HOSTER_ADD_OPTIONS.find(o => o.value === e.target.value);
const authType = opt ? opt.authType : 'login';
const credsContainer = document.getElementById('accountCredsFields');
@@ -5180,15 +5142,6 @@ function setupListeners() {
input.type = input.type === 'password' ? 'text' : 'password';
});
});
- document.getElementById('accountModalStatus').textContent = '';
- document.getElementById('accountModalStatus').className = 'account-modal-status';
- // Hoster changed → any prior validation is stale by construction. Drop the
- // snapshot and revert the button so the user has to re-Prüfen.
- _validatedCreds = null;
- const sb = document.getElementById('saveAccountBtn');
- if (sb) sb.textContent = 'Prüfen';
- // The cred inputs were just replaced — rewire invalidation listeners on
- // the fresh elements so post-validation edits still revert the button.
_wireCredFieldInvalidation();
});
diff --git a/renderer/index.html b/renderer/index.html
index 15b1a93..84a07a9 100644
--- a/renderer/index.html
+++ b/renderer/index.html
@@ -421,6 +421,7 @@
+