From dbdd0878f5f6e755463dbcfb76606bfa76fe6482 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 28 Jan 2026 14:48:06 +0100 Subject: [PATCH] refactor(sync): unify encryption state handling --- e2e/pages/supersync.page.ts | 517 ++++++++++++------ e2e/tests/sync/supersync-encryption.spec.ts | 38 +- ...-change-encryption-password.component.html | 16 + ...-change-encryption-password.component.scss | 15 + ...ange-encryption-password.component.spec.ts | 39 +- ...og-change-encryption-password.component.ts | 31 +- ...g-enter-encryption-password.component.scss | 6 + ...nter-encryption-password.component.spec.ts | 76 +++ ...log-enter-encryption-password.component.ts | 1 + .../dialog-sync-initial-cfg.component.ts | 7 +- .../encryption-password-change.service.ts | 13 +- src/app/imex/sync/sync-config.service.ts | 65 ++- src/app/imex/sync/sync-wrapper.service.ts | 3 + src/app/t.const.ts | 8 + src/assets/i18n/en.json | 10 +- 15 files changed, 653 insertions(+), 192 deletions(-) create mode 100644 src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.spec.ts diff --git a/e2e/pages/supersync.page.ts b/e2e/pages/supersync.page.ts index d58ffddea3..d646be6689 100644 --- a/e2e/pages/supersync.page.ts +++ b/e2e/pages/supersync.page.ts @@ -198,106 +198,261 @@ export class SuperSyncPage extends BasePage { // Fill in access token (this field is NOT in the Advanced section) await this.accessTokenInput.fill(config.accessToken); - // Track if encryption settings were changed (requires fresh sync) - let encryptionSettingsChanged = false; + // Track if this is a fresh setup that needs encryption enabled + // For the FIRST client (Client A), we enable encryption AFTER saving config + // For SUBSEQUENT clients (Client B), we handle the password dialog that appears + // when receiving encrypted data from the server + const needsEncryptionEnabled = config.isEncryptionEnabled && config.password; - // Handle encryption settings if provided - if (config.isEncryptionEnabled !== undefined) { - const isCurrentlyEnabled = await this.disableEncryptionBtn + // Save configuration first (without encryption changes) + // Encryption can only be enabled/disabled after the provider is active + await expect(this.saveBtn).toBeEnabled({ timeout: 5000 }); + await this.saveBtn.click(); + + // Wait for the config dialog to close + // The save() method is async and may take time to complete + // Note: After save, a password dialog might appear if server has encrypted data + // So we check for the config dialog specifically, not just any dialog + await this.page.waitForTimeout(1000); + + // Define locators for dialogs we might see + const configDialog = this.page.locator('dialog-sync-initial-cfg'); + const passwordDialog = this.page.locator('dialog-enter-encryption-password'); + + // Wait for config dialog to close OR password dialog to appear + // (password dialog appearing means config dialog closed and sync started) + const configDialogClosed = await Promise.race([ + configDialog + .waitFor({ state: 'hidden', timeout: 15000 }) + .then(() => 'config_closed'), + passwordDialog + .waitFor({ state: 'visible', timeout: 15000 }) + .then(() => 'password_appeared'), + ]).catch(() => 'timeout'); + + if (configDialogClosed === 'timeout') { + // Neither happened - try pressing Escape to close any stuck dialog + console.log( + '[SuperSyncPage] Config dialog did not close after save, trying Escape', + ); + await this.page.keyboard.press('Escape'); + await this.page.waitForTimeout(500); + } else if (configDialogClosed === 'password_appeared') { + console.log('[SuperSyncPage] Password dialog appeared - config dialog closed'); + } + + // If encryption is needed, handle the flow based on whether server has encrypted data + if (needsEncryptionEnabled && waitForInitialSync) { + // Wait for one of several possible outcomes: + // 1. Password dialog (server has encrypted data, client needs to enter password) + // 2. Fresh client dialog (server has no data or unencrypted data) + // 3. Sync completes (server has no data) + // 4. Sync import conflict dialog + await this.page.waitForTimeout(1000); + + // Define locators for all possible dialogs + const passwordDialog = this.page.locator('dialog-enter-encryption-password'); + const decryptErrorDialog = this.page.locator('dialog-handle-decrypt-error'); + + // Check which dialog appeared (if any) + const passwordDialogVisible = await passwordDialog.isVisible().catch(() => false); + const decryptErrorDialogVisible = await decryptErrorDialog + .isVisible() + .catch(() => false); + const freshDialogVisible = await this.freshClientDialog + .isVisible() + .catch(() => false); + const syncImportDialogVisible = await this.syncImportConflictDialog .isVisible() .catch(() => false); - if (config.isEncryptionEnabled !== isCurrentlyEnabled) { - encryptionSettingsChanged = true; - if (config.isEncryptionEnabled) { - if (!config.password) { - throw new Error('Encryption password required to enable encryption'); - } - await this.encryptionPasswordInput.waitFor({ - state: 'visible', - timeout: 3000, - }); - await this.encryptionPasswordInput.fill(config.password); - await this.enableEncryptionBtn.waitFor({ state: 'visible', timeout: 3000 }); - await this.enableEncryptionBtn.click(); - // Wait for "Enable Encryption?" confirmation dialog - const enableDialog = this.page - .locator('mat-dialog-container') - .filter({ hasText: 'Enable Encryption?' }); - const dialogAppeared = await enableDialog - .waitFor({ state: 'visible', timeout: 5000 }) - .then(() => true) - .catch(() => false); + if (passwordDialogVisible) { + // Server has encrypted data - enter password and sync + console.log('[SuperSyncPage] Password dialog appeared - entering password'); + const passwordInput = passwordDialog.locator('input[type="password"]'); + await passwordInput.fill(config.password!); + const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]'); + await saveAndSyncBtn.click(); + await passwordDialog.waitFor({ state: 'hidden', timeout: 30000 }); + } else if (decryptErrorDialogVisible) { + // Decryption error - this shouldn't happen with correct password + // but handle it gracefully + console.log('[SuperSyncPage] Decrypt error dialog appeared - unexpected'); + throw new Error('Decrypt error dialog appeared - password may be incorrect'); + } else if (freshDialogVisible) { + // Fresh client dialog - server has no encrypted data yet + // This means we're Client A (first client) + console.log('[SuperSyncPage] Fresh client dialog - enabling encryption'); + await this.freshClientConfirmBtn.click(); + await this.freshClientDialog.waitFor({ state: 'hidden', timeout: 5000 }); - if (dialogAppeared) { - // Click the confirm button to enable encryption - const confirmBtn = enableDialog - .locator('button[mat-flat-button]') - .filter({ hasText: /enable encryption/i }); - await confirmBtn.click(); + // Now enable encryption (this is Client A) + await this.ensureOverlaysClosed(); + await this.enableEncryption(config.password!); + } else if (syncImportDialogVisible) { + // Sync import conflict - use remote data + console.log('[SuperSyncPage] Sync import conflict dialog - using remote'); + await this.syncImportUseRemoteBtn.click(); + await this.syncImportConflictDialog.waitFor({ state: 'hidden', timeout: 5000 }); - // Wait for the enable encryption dialog to close - await enableDialog.waitFor({ state: 'hidden', timeout: 60000 }); + // After handling sync import, check for password dialog + const passwordDialogAfterImport = await passwordDialog + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => true) + .catch(() => false); - // Wait for clean slate operation to complete (server wipe + fresh upload) - await this.page.waitForTimeout(3000); - } + if (passwordDialogAfterImport) { + const passwordInput = passwordDialog.locator('input[type="password"]'); + await passwordInput.fill(config.password!); + const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]'); + await saveAndSyncBtn.click(); + await passwordDialog.waitFor({ state: 'hidden', timeout: 30000 }); } else { - await this.disableEncryptionBtn.waitFor({ state: 'visible', timeout: 3000 }); - await this.disableEncryptionBtn.click(); + // No password dialog - enable encryption (this is Client A) + await this.ensureOverlaysClosed(); + await this.enableEncryption(config.password!); + } + } else { + // No dialog appeared - might be first client with empty server + // Wait a bit more for any dialog to appear + await this.page.waitForTimeout(2000); - // Wait for "Disable Encryption?" confirmation dialog - const disableDialog = this.page - .locator('mat-dialog-container') - .filter({ hasText: 'Disable Encryption?' }); - const dialogAppeared = await disableDialog - .waitFor({ state: 'visible', timeout: 5000 }) - .then(() => true) - .catch(() => false); + // Check again for password dialog (might have appeared after sync started) + const passwordDialogLate = await passwordDialog.isVisible().catch(() => false); - if (dialogAppeared) { - // Click the confirm button to disable encryption - const confirmBtn = disableDialog - .locator('button[mat-flat-button]') - .filter({ hasText: /disable encryption/i }); - await confirmBtn.click(); - - // Wait for the disable encryption dialog to close - await disableDialog.waitFor({ state: 'hidden', timeout: 60000 }); - - // Wait for clean slate operation to complete (server wipe + fresh upload) - await this.page.waitForTimeout(3000); - } + if (passwordDialogLate) { + console.log( + '[SuperSyncPage] Password dialog appeared late - entering password', + ); + const passwordInput = passwordDialog.locator('input[type="password"]'); + await passwordInput.fill(config.password!); + const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]'); + await saveAndSyncBtn.click(); + await passwordDialog.waitFor({ state: 'hidden', timeout: 30000 }); + } else { + // No password dialog - this is Client A (first client) + // Enable encryption + console.log( + '[SuperSyncPage] No password dialog - enabling encryption as Client A', + ); + await this.ensureOverlaysClosed(); + await this.enableEncryption(config.password!); } } - } - // Save configuration - but the form might already be closed after disabling encryption - const hasConfigDialog = (await this.page.locator('mat-dialog-container').count()) > 0; - if (hasConfigDialog) { - await expect(this.saveBtn).toBeEnabled({ timeout: 5000 }); - await this.saveBtn.click(); - } + // Wait for sync to complete + const checkAlreadyVisible = await this.syncCheckIcon.isVisible().catch(() => false); - // Wait for the dialog to close - // Skip this check if waitForInitialSync is false, as the sync might trigger - // an error dialog (e.g., decrypt error with wrong password) - if (waitForInitialSync) { + if (!checkAlreadyVisible) { + // Wait for sync to start or complete + const spinnerAppeared = await this.syncSpinner + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + + if (spinnerAppeared) { + await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 }); + } + + // Wait for check icon to appear + await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 10000 }); + } + } else if (waitForInitialSync) { + // No encryption change needed - just wait for dialogs and sync + // Check if a fresh client confirmation dialog appeared + const freshDialogVisible = await this.freshClientDialog + .isVisible() + .catch(() => false); + if (freshDialogVisible) { + await this.freshClientConfirmBtn.click(); + await this.freshClientDialog.waitFor({ state: 'hidden', timeout: 5000 }); + } + + // Check if a sync import conflict dialog appeared + const syncImportDialogVisible = await this.syncImportConflictDialog + .isVisible() + .catch(() => false); + if (syncImportDialogVisible) { + await this.syncImportUseRemoteBtn.click(); + await this.syncImportConflictDialog.waitFor({ state: 'hidden', timeout: 5000 }); + } + + // Wait for all dialogs to close await expect(this.page.locator('mat-dialog-container')).toHaveCount(0, { timeout: 10000, }); + + // Wait for initial sync to complete + const checkAlreadyVisible = await this.syncCheckIcon.isVisible().catch(() => false); + + if (!checkAlreadyVisible) { + const spinnerAppeared = await this.syncSpinner + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false); + + if (spinnerAppeared) { + await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 }); + } + + await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 5000 }); + } + } else if (needsEncryptionEnabled) { + // When waitForInitialSync is false but encryption is needed, + // we need to handle the password dialog that appears when receiving encrypted data + // This is used for testing wrong password scenarios + const passwordDialog = this.page.locator('dialog-enter-encryption-password'); + const decryptErrorDialog = this.page.locator('dialog-handle-decrypt-error'); + + // Wait for password dialog to appear (server has encrypted data) + const passwordDialogAppeared = await passwordDialog + .waitFor({ state: 'visible', timeout: 10000 }) + .then(() => true) + .catch(() => false); + + if (passwordDialogAppeared) { + // Enter the password (which may be wrong for testing purposes) + console.log( + '[SuperSyncPage] Password dialog appeared - entering password (may be wrong)', + ); + const passwordInput = passwordDialog.locator('input[type="password"]'); + await passwordInput.fill(config.password!); + const saveAndSyncBtn = passwordDialog.locator('button[mat-flat-button]'); + await saveAndSyncBtn.click(); + + // Wait for either: + // 1. Password dialog to close (password was correct) + // 2. Decrypt error dialog to appear (password was wrong) + // 3. Sync to complete (password was correct) + const result = await Promise.race([ + passwordDialog + .waitFor({ state: 'hidden', timeout: 30000 }) + .then(() => 'password_dialog_closed'), + decryptErrorDialog + .waitFor({ state: 'visible', timeout: 30000 }) + .then(() => 'decrypt_error'), + ]).catch(() => 'timeout'); + + console.log(`[SuperSyncPage] After entering password: ${result}`); + } else { + // No password dialog - wait for sync to start or error + const syncStartedOrFailed = await Promise.race([ + this.syncSpinner + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => 'spinner'), + this.syncErrorIcon + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => 'error'), + decryptErrorDialog + .waitFor({ state: 'visible', timeout: 5000 }) + .then(() => 'decrypt_error'), + this.page.waitForTimeout(2000).then(() => 'timeout'), + ]).catch(() => 'timeout'); + console.log(`[SuperSyncPage] Sync started or failed: ${syncStartedOrFailed}`); + } } else { - // When waitForInitialSync is false, we expect sync to start and potentially show - // an error dialog (e.g., decrypt error with wrong password). We need to ensure: - // 1. The config dialog save action has been processed - // 2. Sync has started (which may trigger error dialogs) - // 3. We don't close error dialogs - tests want to interact with them - // - // Wait for any of these signals that indicate the config dialog has closed: - // - Sync spinner appears (sync started) - // - Sync error icon appears (sync failed immediately) - // - A different dialog appears (like decrypt error dialog) - // - Timeout fallback (sync might complete very quickly) + // When waitForInitialSync is false and no encryption, + // just wait for sync to start or show an error const syncStartedOrFailed = Promise.race([ this.syncSpinner .waitFor({ state: 'visible', timeout: 5000 }) @@ -312,57 +467,6 @@ export class SuperSyncPage extends BasePage { this.page.waitForTimeout(2000).then(() => 'timeout'), ]); await syncStartedOrFailed; - // NOTE: Do NOT call ensureOverlaysClosed() here - if there's a dialog (like - // decrypt error), we want it to stay open for the test to interact with. - } - - if (waitForInitialSync) { - // If encryption settings changed, we MUST wait for a fresh sync (clean slate operation) - // Don't skip even if check icon is visible from previous sync - if (encryptionSettingsChanged) { - // Trigger a manual sync to ensure we get latest data after encryption change - await this.page.waitForTimeout(500); // Wait for UI to settle - // Ensure any lingering overlays from encryption dialogs are closed - await this.ensureOverlaysClosed(); - await this.syncBtn.click(); - - // Wait for sync to start - const spinnerAppeared = await this.syncSpinner - .waitFor({ state: 'visible', timeout: 5000 }) - .then(() => true) - .catch(() => false); - - if (spinnerAppeared) { - // Wait for sync to complete - await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 }); - } - - // Wait for check icon - await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 10000 }); - } else { - // Check if sync already completed - const checkAlreadyVisible = await this.syncCheckIcon - .isVisible() - .catch(() => false); - - if (!checkAlreadyVisible) { - // Wait for sync to start or complete - // Try to wait for spinner first, but if it's already gone, that's fine - const spinnerAppeared = await this.syncSpinner - .waitFor({ state: 'visible', timeout: 2000 }) - .then(() => true) - .catch(() => false); - - if (spinnerAppeared) { - // Spinner appeared, wait for it to disappear - // Increased timeout from 15s to 30s for multi-client scenarios under load - await this.syncSpinner.waitFor({ state: 'hidden', timeout: 30000 }); - } - - // Now wait for check icon to appear - await this.syncCheckIcon.waitFor({ state: 'visible', timeout: 5000 }); - } - } } } @@ -429,6 +533,8 @@ export class SuperSyncPage extends BasePage { const confirmBtn = enableDialog .locator('button[mat-flat-button]') .filter({ hasText: /enable/i }); + // Wait for button to be visible and clickable (dialog animation may delay rendering) + await confirmBtn.waitFor({ state: 'visible', timeout: 10000 }); await confirmBtn.click(); // Wait for the enable encryption dialog to close @@ -447,19 +553,9 @@ export class SuperSyncPage extends BasePage { timeout: 10000, }); - // CRITICAL: Wait for overlay backdrop to be removed - // Angular Material backdrop removal is asynchronous, and lingering backdrops - // can block subsequent button clicks in tests - const backdrop = this.page.locator('.cdk-overlay-backdrop'); - await backdrop - .first() - .waitFor({ state: 'detached', timeout: 3000 }) - .catch(() => { - // Non-fatal: backdrop might already be gone - }); - - // Wait for any snackbars to dismiss - await this.page.waitForTimeout(1000); + // CRITICAL: Ensure all overlays are properly closed + // This handles backdrop removal and any lingering dialogs + await this.ensureOverlaysClosed(); // Wait for clean slate operation to complete (sync will happen automatically) await this.page.waitForTimeout(3000); @@ -473,7 +569,8 @@ export class SuperSyncPage extends BasePage { */ async disableEncryption(): Promise { // Open sync settings via right-click - await this.syncBtn.click({ button: 'right' }); + // Use noWaitAfter to prevent waiting for navigation events + await this.syncBtn.click({ button: 'right', noWaitAfter: true }); await this.providerSelect.waitFor({ state: 'visible', timeout: 10000 }); // CRITICAL: Select "SuperSync" from provider dropdown to load current configuration @@ -488,12 +585,118 @@ export class SuperSyncPage extends BasePage { const advancedCollapsible = this.page.locator( '.collapsible-header:has-text("Advanced")', ); + + // Debug: Check if Advanced collapsible exists + const advancedExists = await advancedCollapsible.count(); + console.log(`[DisableEncryption] Advanced collapsible count: ${advancedExists}`); + + // Debug: Get all collapsible headers + const allCollapsibles = await this.page + .locator('.collapsible-header') + .allTextContents(); + console.log( + `[DisableEncryption] All collapsible headers: ${JSON.stringify(allCollapsibles)}`, + ); + await advancedCollapsible.waitFor({ state: 'visible', timeout: 5000 }); // Check if already expanded const isExpanded = await this.disableEncryptionBtn.isVisible().catch(() => false); + console.log(`[DisableEncryption] isExpanded before click: ${isExpanded}`); if (!isExpanded) { - await advancedCollapsible.click(); + // Debug: Check if collapsible is clickable + const isClickable = await advancedCollapsible.isEnabled().catch(() => false); + console.log(`[DisableEncryption] Collapsible isEnabled: ${isClickable}`); + + // Scroll the collapsible into view + await advancedCollapsible.scrollIntoViewIfNeeded(); + await this.page.waitForTimeout(300); + + // Try clicking multiple times with different methods + console.log(`[DisableEncryption] Attempting click on collapsible...`); + + // Method 1: Regular click with noWaitAfter to prevent navigation blocking + await advancedCollapsible.click({ noWaitAfter: true }); + await this.page.waitForTimeout(500); + + // Check if expanded + let expandedAfterClick1 = + (await this.page.locator('formly-collapsible.isExpanded').count()) > 0; + console.log(`[DisableEncryption] After click 1: isExpanded=${expandedAfterClick1}`); + + if (!expandedAfterClick1) { + // Method 2: Click with force and noWaitAfter + await advancedCollapsible.click({ force: true, noWaitAfter: true }); + await this.page.waitForTimeout(500); + expandedAfterClick1 = + (await this.page.locator('formly-collapsible.isExpanded').count()) > 0; + console.log( + `[DisableEncryption] After click 2 (force): isExpanded=${expandedAfterClick1}`, + ); + } + + if (!expandedAfterClick1) { + // Method 3: Use JavaScript to click + await advancedCollapsible.evaluate((el) => (el as HTMLElement).click()); + await this.page.waitForTimeout(500); + expandedAfterClick1 = + (await this.page.locator('formly-collapsible.isExpanded').count()) > 0; + console.log( + `[DisableEncryption] After click 3 (JS): isExpanded=${expandedAfterClick1}`, + ); + } + + // Debug: Check collapsible state after click + const collapsibleText = await advancedCollapsible + .textContent() + .catch(() => 'unknown'); + console.log(`[DisableEncryption] Collapsible text after click: ${collapsibleText}`); + + // Debug: Check what buttons are visible in the Advanced section + const enableBtn = await this.enableEncryptionBtn.isVisible().catch(() => false); + const disableBtn = await this.disableEncryptionBtn.isVisible().catch(() => false); + const changeBtn = await this.page + .locator('button:has-text("Change Password")') + .isVisible() + .catch(() => false); + + // Debug: Check if the Advanced section content is visible at all + const advancedContent = await this.page + .locator('.collapsible-content') + .first() + .isVisible() + .catch(() => false); + const encryptKeyInput = await this.encryptionPasswordInput + .isVisible() + .catch(() => false); + + // Debug: Check the selected provider + const selectedProvider = await this.providerSelect + .textContent() + .catch(() => 'unknown'); + + // Debug: Check if there are any formly fields inside the collapsible + const formlyFieldsInCollapsible = await this.page + .locator('.collapsible-content formly-field') + .count(); + + // Debug: Check if there are any hidden formly fields + const hiddenFormlyFields = await this.page + .locator('.collapsible-content formly-field[hidden]') + .count(); + + // Debug: Check if the collapsible panel exists (expanded state) + const collapsiblePanel = await this.page.locator('.collapsible-panel').count(); + + // Debug: Check if formly-collapsible has isExpanded class + const isCollapsibleExpanded = await this.page + .locator('formly-collapsible.isExpanded') + .count(); + + console.log( + `[DisableEncryption] After expand - enableBtn: ${enableBtn}, disableBtn: ${disableBtn}, changeBtn: ${changeBtn}, advancedContent: ${advancedContent}, encryptKeyInput: ${encryptKeyInput}, selectedProvider: ${selectedProvider}, formlyFieldsInCollapsible: ${formlyFieldsInCollapsible}, hiddenFormlyFields: ${hiddenFormlyFields}, collapsiblePanel: ${collapsiblePanel}, isCollapsibleExpanded: ${isCollapsibleExpanded}`, + ); + await this.disableEncryptionBtn.waitFor({ state: 'visible', timeout: 3000 }); } @@ -517,6 +720,8 @@ export class SuperSyncPage extends BasePage { const confirmBtn = confirmDialog .locator('button[mat-flat-button]') .filter({ hasText: /disable encryption/i }); + // Wait for button to be visible and clickable (dialog animation may delay rendering) + await confirmBtn.waitFor({ state: 'visible', timeout: 10000 }); await confirmBtn.click(); // Wait for the disable encryption dialog to close and the operation to complete @@ -536,19 +741,9 @@ export class SuperSyncPage extends BasePage { timeout: 10000, }); - // CRITICAL: Wait for overlay backdrop to be removed - // Angular Material backdrop removal is asynchronous, and lingering backdrops - // can block subsequent button clicks in tests (e.g., setupSuperSync called right after) - const backdrop = this.page.locator('.cdk-overlay-backdrop'); - await backdrop - .first() - .waitFor({ state: 'detached', timeout: 3000 }) - .catch(() => { - // Non-fatal: backdrop might already be gone - }); - - // Wait for any snackbars to dismiss - await this.page.waitForTimeout(1000); + // CRITICAL: Ensure all overlays are properly closed + // This handles backdrop removal and any lingering dialogs + await this.ensureOverlaysClosed(); // Wait for clean slate operation to complete (sync will happen automatically) await this.page.waitForTimeout(3000); diff --git a/e2e/tests/sync/supersync-encryption.spec.ts b/e2e/tests/sync/supersync-encryption.spec.ts index 8ca0494951..45f3cdc60a 100644 --- a/e2e/tests/sync/supersync-encryption.spec.ts +++ b/e2e/tests/sync/supersync-encryption.spec.ts @@ -113,17 +113,35 @@ test.describe('@supersync SuperSync Encryption', () => { waitForInitialSync: false, // Sync will fail with wrong password }); - // Try to sync - expectation is that it completes (download happens) but processing fails - // The SyncPage helper might throw if it detects an error icon, or we check for error manually + // The sync already happened during setupSuperSync with wrong password + // A decrypt error dialog should appear - wait for it and close it + const decryptErrorDialog = clientC.page.locator('dialog-handle-decrypt-error'); - // We expect the sync to technically "fail" or show an error state because decryption failed - try { - await clientC.sync.triggerSync(); - // It might not throw immediately if waitForSyncComplete handles the error state gracefully or we catch it - await clientC.sync.waitForSyncComplete(); - } catch (e) { - // Expected error or timeout due to failure - console.log('Sync failed as expected:', e); + // Wait for the decrypt error dialog to appear (it may take a moment after sync fails) + const decryptErrorAppeared = await decryptErrorDialog + .waitFor({ state: 'visible', timeout: 10000 }) + .then(() => true) + .catch(() => false); + + if (decryptErrorAppeared) { + console.log('Decrypt error dialog appeared - closing it'); + // Close the dialog by clicking cancel or pressing Escape + const cancelBtn = decryptErrorDialog + .locator('button') + .filter({ hasText: /cancel/i }); + const cancelVisible = await cancelBtn.isVisible().catch(() => false); + if (cancelVisible) { + await cancelBtn.click(); + } else { + await clientC.page.keyboard.press('Escape'); + } + await decryptErrorDialog.waitFor({ state: 'hidden', timeout: 5000 }); + } else { + console.log( + 'No decrypt error dialog appeared - checking for other error indicators', + ); + // The error might be shown via snackbar instead of dialog + // Just proceed to verify error state } // Verify Client C DOES NOT have the task diff --git a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.html b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.html index 7740cf0ae6..9135755b36 100644 --- a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.html +++ b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.html @@ -47,6 +47,22 @@ + @if (providerType === 'supersync') { +
+

{{ textKeys.FORCE_OVERWRITE_INFO | translate }}

+ +
+ } +
diff --git a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.scss b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.scss index 4e92cc9bea..431d958b31 100644 --- a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.scss +++ b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.scss @@ -28,6 +28,21 @@ mat-divider { margin: 24px 0; } +.force-overwrite-section { + margin: 8px 0 16px; + + p { + margin: 0 0 8px; + font-size: 13px; + color: var(--c-dark-60); + line-height: 1.5; + + @include darkTheme { + color: rgba(255, 255, 255, 0.6); + } + } +} + .remove-encryption-section { h3 { margin: 0 0 8px; diff --git a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.spec.ts b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.spec.ts index c3fdb0a32c..eb6f4796e2 100644 --- a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.spec.ts +++ b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.spec.ts @@ -1,7 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { MatDialogRef } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; import { DialogChangeEncryptionPasswordComponent, ChangeEncryptionPasswordResult, @@ -21,6 +22,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => { let mockFileBasedEncryptionService: jasmine.SpyObj; let mockSnackService: jasmine.SpyObj; let mockEncryptionDisableService: jasmine.SpyObj; + let mockMatDialog: jasmine.SpyObj; beforeEach(async () => { mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); @@ -36,6 +38,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => { 'disableEncryption', 'disableEncryptionForFileBased', ]); + mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); await TestBed.configureTestingModule({ imports: [ @@ -55,6 +58,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => { }, { provide: SnackService, useValue: mockSnackService }, { provide: EncryptionDisableService, useValue: mockEncryptionDisableService }, + { provide: MatDialog, useValue: mockMatDialog }, ], }).compileComponents(); @@ -150,6 +154,7 @@ describe('DialogChangeEncryptionPasswordComponent', () => { expect(mockEncryptionPasswordChangeService.changePassword).toHaveBeenCalledWith( 'password123', + undefined, ); expect(mockSnackService.open).toHaveBeenCalledWith( jasmine.objectContaining({ type: 'SUCCESS' }), @@ -194,6 +199,38 @@ describe('DialogChangeEncryptionPasswordComponent', () => { }); }); + describe('confirmForceOverwrite', () => { + it('should confirm and force overwrite with allowUnsyncedOps', async () => { + component.newPassword = 'password123'; + component.confirmPassword = 'password123'; + mockEncryptionPasswordChangeService.changePassword.and.returnValue( + Promise.resolve(), + ); + const confirmDialogRef = jasmine.createSpyObj('MatDialogRef', ['afterClosed']); + confirmDialogRef.afterClosed.and.returnValue(of(true)); + mockMatDialog.open.and.returnValue(confirmDialogRef); + + await component.confirmForceOverwrite(); + + expect(mockEncryptionPasswordChangeService.changePassword).toHaveBeenCalledWith( + 'password123', + { allowUnsyncedOps: true }, + ); + }); + + it('should not proceed when confirmation is cancelled', async () => { + component.newPassword = 'password123'; + component.confirmPassword = 'password123'; + const confirmDialogRef = jasmine.createSpyObj('MatDialogRef', ['afterClosed']); + confirmDialogRef.afterClosed.and.returnValue(of(false)); + mockMatDialog.open.and.returnValue(confirmDialogRef); + + await component.confirmForceOverwrite(); + + expect(mockEncryptionPasswordChangeService.changePassword).not.toHaveBeenCalled(); + }); + }); + describe('cancel', () => { it('should close dialog with success: false', () => { component.cancel(); diff --git a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.ts b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.ts index 9f18d99ed2..3a6ba347e2 100644 --- a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.ts +++ b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.ts @@ -1,6 +1,7 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { MAT_DIALOG_DATA, + MatDialog, MatDialogActions, MatDialogContent, MatDialogRef, @@ -19,6 +20,7 @@ import { SnackService } from '../../../core/snack/snack.service'; import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatDivider } from '@angular/material/divider'; import { FileBasedEncryptionService } from '../file-based-encryption.service'; +import { DialogConfirmComponent } from '../../../ui/dialog-confirm/dialog-confirm.component'; export interface ChangeEncryptionPasswordResult { success: boolean; @@ -61,6 +63,7 @@ export class DialogChangeEncryptionPasswordComponent { private _fileBasedEncryptionService = inject(FileBasedEncryptionService); private _encryptionDisableService = inject(EncryptionDisableService); private _snackService = inject(SnackService); + private _matDialog = inject(MatDialog); private _matDialogRef = inject< MatDialogRef< @@ -92,7 +95,7 @@ export class DialogChangeEncryptionPasswordComponent { return this.newPassword.length >= 8 && this.passwordsMatch; } - async confirm(): Promise { + async confirm(options?: { allowUnsyncedOps?: boolean }): Promise { if (!this.isValid || this.isLoading()) { return; } @@ -103,7 +106,10 @@ export class DialogChangeEncryptionPasswordComponent { if (this.providerType === 'file-based') { await this._fileBasedEncryptionService.changePassword(this.newPassword); } else { - await this._encryptionPasswordChangeService.changePassword(this.newPassword); + await this._encryptionPasswordChangeService.changePassword( + this.newPassword, + options, + ); } this._snackService.open({ type: 'SUCCESS', @@ -120,6 +126,27 @@ export class DialogChangeEncryptionPasswordComponent { } } + async confirmForceOverwrite(): Promise { + if (this.providerType !== 'supersync' || !this.isValid || this.isLoading()) { + return; + } + + const confirmed = await this._matDialog + .open(DialogConfirmComponent, { + data: { + title: this.textKeys.FORCE_OVERWRITE_TITLE, + message: this.textKeys.FORCE_OVERWRITE_CONFIRM, + okTxt: this.textKeys.BTN_FORCE_OVERWRITE, + }, + }) + .afterClosed() + .toPromise(); + + if (confirmed) { + await this.confirm({ allowUnsyncedOps: true }); + } + } + cancel(): void { this._matDialogRef.close({ success: false }); } diff --git a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.scss b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.scss index 4414ce77f8..46eb8fef2a 100644 --- a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.scss +++ b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.scss @@ -5,6 +5,12 @@ line-height: 1.5; margin-bottom: 16px; } + + .force-overwrite-note { + margin-top: 12px; + color: var(--c-dark-60); + font-size: 13px; + } } :host mat-form-field { diff --git a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.spec.ts b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.spec.ts new file mode 100644 index 0000000000..6d444c9f2b --- /dev/null +++ b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.spec.ts @@ -0,0 +1,76 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { TranslateModule } from '@ngx-translate/core'; +import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password.component'; +import { SyncConfigService } from '../sync-config.service'; +import { EncryptionPasswordChangeService } from '../encryption-password-change.service'; +import { SnackService } from '../../../core/snack/snack.service'; +import { SyncProviderManager } from '../../../op-log/sync-providers/provider-manager.service'; +import { SyncProviderId } from '../../../op-log/sync-providers/provider.const'; + +describe('DialogEnterEncryptionPasswordComponent', () => { + let component: DialogEnterEncryptionPasswordComponent; + let fixture: ComponentFixture; + let mockDialogRef: jasmine.SpyObj>; + let mockSyncConfigService: jasmine.SpyObj; + let mockEncryptionPasswordChangeService: jasmine.SpyObj; + let mockSnackService: jasmine.SpyObj; + let mockMatDialog: jasmine.SpyObj; + let mockProviderManager: jasmine.SpyObj; + + beforeEach(async () => { + mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + mockSyncConfigService = jasmine.createSpyObj('SyncConfigService', [ + 'updateEncryptionPassword', + ]); + mockEncryptionPasswordChangeService = jasmine.createSpyObj( + 'EncryptionPasswordChangeService', + ['changePassword'], + ); + mockSnackService = jasmine.createSpyObj('SnackService', ['open']); + mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); + mockProviderManager = jasmine.createSpyObj('SyncProviderManager', [ + 'getActiveProvider', + ]); + mockProviderManager.getActiveProvider.and.returnValue({ + id: SyncProviderId.SuperSync, + } as any); + + await TestBed.configureTestingModule({ + imports: [ + DialogEnterEncryptionPasswordComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { provide: MatDialogRef, useValue: mockDialogRef }, + { provide: SyncConfigService, useValue: mockSyncConfigService }, + { + provide: EncryptionPasswordChangeService, + useValue: mockEncryptionPasswordChangeService, + }, + { provide: SnackService, useValue: mockSnackService }, + { provide: MatDialog, useValue: mockMatDialog }, + { provide: SyncProviderManager, useValue: mockProviderManager }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DialogEnterEncryptionPasswordComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should reset loading and show error when saveAndSync fails', async () => { + component.passwordVal = 'password123'; + mockSyncConfigService.updateEncryptionPassword.and.returnValue( + Promise.reject(new Error('fail')), + ); + + await component.saveAndSync(); + + expect(component.isLoading()).toBe(false); + expect(mockSnackService.open).toHaveBeenCalled(); + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.ts b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.ts index ab49cda6e9..637f6b3f59 100644 --- a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.ts +++ b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.ts @@ -82,6 +82,7 @@ export class DialogEnterEncryptionPasswordComponent { type: 'ERROR', msg: T.F.SYNC.S.PERSIST_FAILED, }); + } finally { this.isLoading.set(false); } } diff --git a/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts b/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts index 08c047cef1..4d9b3c72ee 100644 --- a/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts +++ b/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts @@ -66,6 +66,7 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit { syncProvider: null, syncInterval: 300000, encryptKey: '', + isEncryptionEnabled: false, localFileSync: {}, webDav: {}, superSync: {}, @@ -135,6 +136,8 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit { providerSpecificUpdate = { superSync: privateCfg as any, encryptKey: privateCfg.encryptKey || '', + // SuperSync stores isEncryptionEnabled in privateCfg, not globalCfg + isEncryptionEnabled: (privateCfg as any).isEncryptionEnabled || false, }; } else if (newProvider === LegacySyncProvider.WebDAV && privateCfg) { providerSpecificUpdate = { @@ -214,6 +217,8 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit { } updateTmpCfg(cfg: SyncConfig): void { - this._tmpUpdatedCfg = cfg; + // Use Object.assign to preserve the object reference for Formly + // This ensures Formly detects changes to the model + Object.assign(this._tmpUpdatedCfg, cfg); } } diff --git a/src/app/imex/sync/encryption-password-change.service.ts b/src/app/imex/sync/encryption-password-change.service.ts index 0addd57d82..26e7e69f11 100644 --- a/src/app/imex/sync/encryption-password-change.service.ts +++ b/src/app/imex/sync/encryption-password-change.service.ts @@ -51,7 +51,10 @@ export class EncryptionPasswordChangeService { * @param newPassword - The new encryption password * @throws Error if sync provider is not SuperSync or not ready */ - async changePassword(newPassword: string): Promise { + async changePassword( + newPassword: string, + options?: { allowUnsyncedOps?: boolean }, + ): Promise { SyncLog.normal('EncryptionPasswordChangeService: Starting password change...'); // Get the sync provider @@ -74,12 +77,18 @@ export class EncryptionPasswordChangeService { const unsyncedUserOps = unsyncedOps.filter( (entry) => !isFullStateOpType(entry.op.opType), ); - if (unsyncedUserOps.length > 0) { + if (unsyncedUserOps.length > 0 && !options?.allowUnsyncedOps) { throw new Error( `Cannot change password: ${unsyncedUserOps.length} operation(s) have not been synced yet. ` + 'Please wait for sync to complete or manually trigger a sync before changing the password.', ); } + if (unsyncedUserOps.length > 0 && options?.allowUnsyncedOps) { + SyncLog.warn( + `EncryptionPasswordChangeService: Proceeding with password change despite ` + + `${unsyncedUserOps.length} unsynced operation(s) (force overwrite).`, + ); + } // Get current config const existingCfg = diff --git a/src/app/imex/sync/sync-config.service.ts b/src/app/imex/sync/sync-config.service.ts index e1c1801b16..5d44a11137 100644 --- a/src/app/imex/sync/sync-config.service.ts +++ b/src/app/imex/sync/sync-config.service.ts @@ -4,7 +4,11 @@ import { GlobalConfigService } from '../../features/config/global-config.service import { combineLatest, from, Observable, of } from 'rxjs'; import { SyncConfig } from '../../features/config/global-config.model'; import { switchMap, tap } from 'rxjs/operators'; -import { PrivateCfgByProviderId, SyncProviderId } from '../../op-log/sync-exports'; +import { + CurrentProviderPrivateCfg, + PrivateCfgByProviderId, + SyncProviderId, +} from '../../op-log/sync-exports'; import { DEFAULT_GLOBAL_CONFIG } from '../../features/config/default-global-config.const'; import { SyncLog } from '../../core/log'; import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cache.service'; @@ -91,6 +95,43 @@ export class SyncConfigService { private _lastSettings: SyncConfig | null = null; + private _deriveEncryptionState( + baseConfig: SyncConfig, + currentProviderCfg: CurrentProviderPrivateCfg | null, + ): { isEncryptionEnabled: boolean; encryptKey: string } { + if (!currentProviderCfg) { + return { + isEncryptionEnabled: baseConfig.isEncryptionEnabled ?? false, + encryptKey: '', + }; + } + + const privateCfg = currentProviderCfg.privateCfg as { + encryptKey?: string; + isEncryptionEnabled?: boolean; + } | null; + if (!privateCfg) { + return { + isEncryptionEnabled: baseConfig.isEncryptionEnabled ?? false, + encryptKey: '', + }; + } + + const encryptKey = privateCfg.encryptKey ?? ''; + + if (currentProviderCfg.providerId === SyncProviderId.SuperSync) { + return { + isEncryptionEnabled: privateCfg.isEncryptionEnabled ?? false, + encryptKey, + }; + } + + return { + isEncryptionEnabled: !!encryptKey, + encryptKey, + }; + } + readonly syncSettingsForm$: Observable = combineLatest([ this._globalConfigService.sync$, this._providerManager.currentProviderPrivateCfg$, @@ -147,17 +188,10 @@ export class SyncConfigService { const prop = PROP_MAP_TO_FORM[currentProviderCfg.providerId]; - const providerEncryptKey = ( - currentProviderCfg.privateCfg as { encryptKey?: string } | null - )?.encryptKey; - - // For SuperSync, extract isEncryptionEnabled from privateCfg - // For file-based providers, treat encryptKey as the source of truth - const isEncryptionEnabled = - currentProviderCfg.providerId === SyncProviderId.SuperSync - ? ((currentProviderCfg.privateCfg as { isEncryptionEnabled?: boolean } | null) - ?.isEncryptionEnabled ?? false) - : !!providerEncryptKey || (baseConfig.isEncryptionEnabled ?? false); + const { isEncryptionEnabled, encryptKey } = this._deriveEncryptionState( + baseConfig, + currentProviderCfg, + ); // DEBUG: Log the encryption state SyncLog.log( @@ -166,7 +200,7 @@ export class SyncConfigService { 'providerId:', currentProviderCfg.providerId, 'privateCfg.encryptKey:', - providerEncryptKey, + encryptKey, 'privateCfg.isEncryptionEnabled:', (currentProviderCfg.privateCfg as { isEncryptionEnabled?: boolean } | null) ?.isEncryptionEnabled, @@ -175,7 +209,7 @@ export class SyncConfigService { // Create config with provider-specific settings const result: SyncConfig = { ...baseConfig, - encryptKey: currentProviderCfg?.privateCfg?.encryptKey || '', + encryptKey, isEncryptionEnabled, // Reset provider-specific configs to defaults first localFileSync: DEFAULT_GLOBAL_CONFIG.sync.localFileSync, @@ -225,6 +259,9 @@ export class SyncConfigService { await this._providerManager.setProviderConfig(activeProvider.id, newConfig); + // Ensure global config reflects encryption enabled when password is entered + this._globalConfigService.updateSection('sync', { isEncryptionEnabled: true }); + // Clear cached encryption keys to force re-derivation with new password this._derivedKeyCache.clearCache(); // Clear cached adapters to force recreation with new encryption settings diff --git a/src/app/imex/sync/sync-wrapper.service.ts b/src/app/imex/sync/sync-wrapper.service.ts index 5728e4900e..232c1254de 100644 --- a/src/app/imex/sync/sync-wrapper.service.ts +++ b/src/app/imex/sync/sync-wrapper.service.ts @@ -672,6 +672,9 @@ export class SyncWrapperService { if (result?.password) { // Password was entered and saved, re-sync this.sync(); + } else if (result?.forceOverwrite) { + // Force overwrite succeeded; reflect synced status + this._providerManager.setSyncStatus('IN_SYNC'); } else { // User cancelled - set status to unknown this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED'); diff --git a/src/app/t.const.ts b/src/app/t.const.ts index a84ad6bcc5..efeb8a294a 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -969,6 +969,10 @@ const T = { MESSAGE: 'F.SYNC.D_ENTER_PASSWORD.MESSAGE', PASSWORD_LABEL: 'F.SYNC.D_ENTER_PASSWORD.PASSWORD_LABEL', BTN_SAVE_AND_SYNC: 'F.SYNC.D_ENTER_PASSWORD.BTN_SAVE_AND_SYNC', + FORCE_OVERWRITE_INFO: 'F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_INFO', + FORCE_OVERWRITE_TITLE: 'F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_TITLE', + FORCE_OVERWRITE_CONFIRM: 'F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_CONFIRM', + BTN_FORCE_OVERWRITE: 'F.SYNC.D_ENTER_PASSWORD.BTN_FORCE_OVERWRITE', }, D_FRESH_CLIENT_CONFIRM: { MESSAGE: 'F.SYNC.D_FRESH_CLIENT_CONFIRM.MESSAGE', @@ -1052,12 +1056,16 @@ const T = { L_CHANGE_ENCRYPTION_PASSWORD: 'F.SYNC.FORM.SUPER_SYNC.L_CHANGE_ENCRYPTION_PASSWORD', CHANGE_PASSWORD_WARNING: 'F.SYNC.FORM.SUPER_SYNC.CHANGE_PASSWORD_WARNING', + FORCE_OVERWRITE_INFO: 'F.SYNC.FORM.SUPER_SYNC.FORCE_OVERWRITE_INFO', + FORCE_OVERWRITE_TITLE: 'F.SYNC.FORM.SUPER_SYNC.FORCE_OVERWRITE_TITLE', + FORCE_OVERWRITE_CONFIRM: 'F.SYNC.FORM.SUPER_SYNC.FORCE_OVERWRITE_CONFIRM', CHANGE_PASSWORD_SUCCESS: 'F.SYNC.FORM.SUPER_SYNC.CHANGE_PASSWORD_SUCCESS', L_NEW_PASSWORD: 'F.SYNC.FORM.SUPER_SYNC.L_NEW_PASSWORD', L_CONFIRM_PASSWORD: 'F.SYNC.FORM.SUPER_SYNC.L_CONFIRM_PASSWORD', PASSWORDS_DONT_MATCH: 'F.SYNC.FORM.SUPER_SYNC.PASSWORDS_DONT_MATCH', PASSWORD_MIN_LENGTH: 'F.SYNC.FORM.SUPER_SYNC.PASSWORD_MIN_LENGTH', BTN_CHANGE_PASSWORD: 'F.SYNC.FORM.SUPER_SYNC.BTN_CHANGE_PASSWORD', + BTN_FORCE_OVERWRITE: 'F.SYNC.FORM.SUPER_SYNC.BTN_FORCE_OVERWRITE', PASSWORD_SET_INFO: 'F.SYNC.FORM.SUPER_SYNC.PASSWORD_SET_INFO', DISABLE_ENCRYPTION_TITLE: 'F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_TITLE', DISABLE_ENCRYPTION_WARNING: 'F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_WARNING', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 685485aa74..a1a7b02f91 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -943,7 +943,11 @@ "TITLE": "Encryption Password Required", "MESSAGE": "The sync server contains encrypted data, but no encryption password is configured on this device. Please enter your encryption password to decrypt and sync your data.", "PASSWORD_LABEL": "Encryption Password", - "BTN_SAVE_AND_SYNC": "Save & Sync" + "BTN_SAVE_AND_SYNC": "Save & Sync", + "FORCE_OVERWRITE_INFO": "If you don’t know the old password, you can overwrite the server with your local data and a new password.", + "FORCE_OVERWRITE_TITLE": "Overwrite Server Data?", + "FORCE_OVERWRITE_CONFIRM": "This will delete all data on the server and upload your local data with the new password. Unsynced local changes may be lost. Continue?", + "BTN_FORCE_OVERWRITE": "Force Overwrite Server" }, "D_FRESH_CLIENT_CONFIRM": { "MESSAGE": "This appears to be a fresh installation. Remote data with {{count}} changes was found. Do you want to download and apply this data?", @@ -1025,12 +1029,16 @@ "ENCRYPTION_WARNING": "WARNING: If you forget your encryption password, your data cannot be recovered. This password is separate from your login password. You must use the same password on all devices.", "L_CHANGE_ENCRYPTION_PASSWORD": "Change Encryption Password", "CHANGE_PASSWORD_WARNING": "WARNING: This will permanently delete ALL sync history and backups on the server. Previous versions cannot be recovered. Your current data will be re-uploaded with the new password. All other devices will need the new password.", + "FORCE_OVERWRITE_INFO": "If you can't sync because you don't know the current encryption password, you can overwrite the server with your local data and a new password.", + "FORCE_OVERWRITE_TITLE": "Overwrite Server Data?", + "FORCE_OVERWRITE_CONFIRM": "This will delete all data on the server and upload your local data with the new password. Unsynced local changes may be lost. Continue?", "CHANGE_PASSWORD_SUCCESS": "Encryption password changed successfully", "L_NEW_PASSWORD": "New Encryption Password", "L_CONFIRM_PASSWORD": "Confirm Password", "PASSWORDS_DONT_MATCH": "Passwords do not match", "PASSWORD_MIN_LENGTH": "Password must be at least 8 characters", "BTN_CHANGE_PASSWORD": "Change Password", + "BTN_FORCE_OVERWRITE": "Force Overwrite Server", "PASSWORD_SET_INFO": "✓ Encryption password is set. Use the 'Change Password' button to modify it.", "L_REMOVE_ENCRYPTION": "Or Remove Encryption", "REMOVE_ENCRYPTION_WARNING": "Removing encryption will delete all server data and re-upload your data without encryption. All devices will need to reconfigure.",