mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
refactor(sync): unify encryption state handling
This commit is contained in:
parent
7d84a36b4b
commit
dbdd0878f5
15 changed files with 653 additions and 192 deletions
|
|
@ -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<void> {
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -47,6 +47,22 @@
|
|||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
@if (providerType === 'supersync') {
|
||||
<div class="force-overwrite-section">
|
||||
<p>{{ textKeys.FORCE_OVERWRITE_INFO | translate }}</p>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="!isValid || isLoading()"
|
||||
(click)="confirmForceOverwrite()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">warning</mat-icon>
|
||||
{{ textKeys.BTN_FORCE_OVERWRITE | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="remove-encryption-section">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<FileBasedEncryptionService>;
|
||||
let mockSnackService: jasmine.SpyObj<SnackService>;
|
||||
let mockEncryptionDisableService: jasmine.SpyObj<EncryptionDisableService>;
|
||||
let mockMatDialog: jasmine.SpyObj<MatDialog>;
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
async confirm(options?: { allowUnsyncedOps?: boolean }): Promise<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<DialogEnterEncryptionPasswordComponent>;
|
||||
let mockDialogRef: jasmine.SpyObj<MatDialogRef<DialogEnterEncryptionPasswordComponent>>;
|
||||
let mockSyncConfigService: jasmine.SpyObj<SyncConfigService>;
|
||||
let mockEncryptionPasswordChangeService: jasmine.SpyObj<EncryptionPasswordChangeService>;
|
||||
let mockSnackService: jasmine.SpyObj<SnackService>;
|
||||
let mockMatDialog: jasmine.SpyObj<MatDialog>;
|
||||
let mockProviderManager: jasmine.SpyObj<SyncProviderManager>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -82,6 +82,7 @@ export class DialogEnterEncryptionPasswordComponent {
|
|||
type: 'ERROR',
|
||||
msg: T.F.SYNC.S.PERSIST_FAILED,
|
||||
});
|
||||
} finally {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
async changePassword(
|
||||
newPassword: string,
|
||||
options?: { allowUnsyncedOps?: boolean },
|
||||
): Promise<void> {
|
||||
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 =
|
||||
|
|
|
|||
|
|
@ -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<SyncConfig> = 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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 <strong>delete all data on the server</strong> 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 <strong>delete all data on the server</strong> 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.",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue