mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(sync): add encryption password change feature for SuperSync
Implements the ability to change the encryption password by deleting all server data and uploading a fresh snapshot with the new password. Server changes: - Add DELETE /api/sync/data endpoint to delete all user sync data - Add deleteAllUserData() method to SyncService Client changes: - Add deleteAllData() to OperationSyncCapable interface - Implement deleteAllData() in SuperSync provider - Add EncryptionPasswordChangeService to orchestrate password change - Add DialogChangeEncryptionPasswordComponent with validation - Add "Change Encryption Password" button to sync settings (visible when encryption is enabled) - Add translations for all new UI strings Testing: - Add 10 unit tests for EncryptionPasswordChangeService - Add 14 unit tests for DialogChangeEncryptionPasswordComponent - Add 5 E2E tests for complete password change flow - Add changeEncryptionPassword() helper to SuperSyncPage Also fixes: - Add missing deleteAllData() to MockOperationSyncProvider - Fix typo S_FINISH_DAY_SYNC_ERROR -> FINISH_DAY_SYNC_ERROR
This commit is contained in:
parent
c757ff500d
commit
c4cc32da29
19 changed files with 1646 additions and 48 deletions
277
docs/ai/supersync-encryption-architecture.md
Normal file
277
docs/ai/supersync-encryption-architecture.md
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
# SuperSync End-to-End Encryption Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
SuperSync uses **AES-256-GCM** encryption with **Argon2id** key derivation for end-to-end encryption (E2EE). The server never sees plaintext data - all encryption/decryption happens client-side.
|
||||
|
||||
## Encryption Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CLIENT A (Upload) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. User Action │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Add Task │ │
|
||||
│ │ "Buy milk" │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 2. NgRx Action Dispatched │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ { type: '[Task] Add Task', │ │
|
||||
│ │ task: { id: 'abc123', title: 'Buy milk', ... }, │ │
|
||||
│ │ meta: { isPersistent: true, entityType: 'task', ... } } │ │
|
||||
│ └──────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 3. Operation Capture (operation-capture.meta-reducer.ts) │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ MultiEntityPayload { │ │
|
||||
│ │ actionPayload: { task: {...}, isAddToBottom: false, ... }, │ │
|
||||
│ │ entityChanges: [{ entityType: 'task', entityId: 'abc123', │ │
|
||||
│ │ changeType: 'create' }] │ │
|
||||
│ │ } │ │
|
||||
│ └──────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 4. Encryption (operation-encryption.service.ts) │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ User Password: "mySecretPass123" │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────┐ │ │
|
||||
│ │ │ Argon2id │ Key Derivation │ │
|
||||
│ │ │ + Salt │ (CPU/memory-hard) │ │
|
||||
│ │ └────────┬────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ 256-bit Encryption Key │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────┐ │ │
|
||||
│ │ │ AES-256-GCM │ Authenticated Encryption │ │
|
||||
│ │ │ + Random IV │ (confidentiality + integrity) │ │
|
||||
│ │ └────────┬────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ Encrypted Payload (base64 string) │ │
|
||||
│ │ "U2FsdGVkX1+abc123..." │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 5. SyncOperation Ready for Upload │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ { id: 'op-xyz', clientId: 'client-A', │ │
|
||||
│ │ actionType: '[Task] Add Task', │ │
|
||||
│ │ payload: "U2FsdGVkX1+abc123...", ← Encrypted! │ │
|
||||
│ │ isPayloadEncrypted: true, ← Flag set │ │
|
||||
│ │ vectorClock: { 'client-A': 5 }, ... } │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SUPERSYNC SERVER │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Server stores encrypted payload AS-IS │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ operations table: │ │
|
||||
│ │ ┌─────────┬────────────────────────────┬───────────────────┐ │ │
|
||||
│ │ │ seq │ payload │ is_encrypted │ │ │
|
||||
│ │ ├─────────┼────────────────────────────┼───────────────────┤ │ │
|
||||
│ │ │ 42 │ "U2FsdGVkX1+abc123..." │ true │ │ │
|
||||
│ │ └─────────┴────────────────────────────┴───────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ⚠️ Server CANNOT read payload contents │ │
|
||||
│ │ ⚠️ Server has NO access to encryption key │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ CLIENT B (Download) │
|
||||
├─────────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Download Operations (operation-log-download.service.ts) │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Received: { payload: "U2FsdGVkX1+abc123...", │ │
|
||||
│ │ isPayloadEncrypted: true, ... } │ │
|
||||
│ └──────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 2. Decryption (operation-encryption.service.ts) │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ User Password: "mySecretPass123" (same as Client A) │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────┐ │ │
|
||||
│ │ │ Argon2id │ Same key derivation │ │
|
||||
│ │ │ + Salt │ → Same 256-bit key │ │
|
||||
│ │ └────────┬────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌─────────────────┐ │ │
|
||||
│ │ │ AES-256-GCM │ Decrypt + verify integrity │ │
|
||||
│ │ │ Decrypt │ │ │
|
||||
│ │ └────────┬────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ Original Payload (JSON) │ │
|
||||
│ │ { actionPayload: { task: {...} }, entityChanges: [...] } │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 3. Convert to Action (operation-converter.util.ts) │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ extractActionPayload() → { task: {...}, isAddToBottom, ... } │ │
|
||||
│ └──────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 4. Dispatch Action (operation-applier.service.ts) │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ { type: '[Task] Add Task', │ │
|
||||
│ │ task: { id: 'abc123', title: 'Buy milk', ... }, │ │
|
||||
│ │ meta: { isPersistent: true, isRemote: true, ... } } │ │
|
||||
│ └──────────────────────────┬───────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ 5. State Updated │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ Task appears │ │
|
||||
│ │ "Buy milk" │ │
|
||||
│ └──────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Components
|
||||
|
||||
### 1. OperationEncryptionService
|
||||
|
||||
**Location**: `src/app/core/persistence/operation-log/sync/operation-encryption.service.ts`
|
||||
|
||||
```typescript
|
||||
// Encrypt before upload
|
||||
async encryptOperation(op: SyncOperation, encryptKey: string): Promise<SyncOperation> {
|
||||
const payloadStr = JSON.stringify(op.payload);
|
||||
const encryptedPayload = await encrypt(payloadStr, encryptKey);
|
||||
return { ...op, payload: encryptedPayload, isPayloadEncrypted: true };
|
||||
}
|
||||
|
||||
// Decrypt after download
|
||||
async decryptOperation(op: SyncOperation, encryptKey: string): Promise<SyncOperation> {
|
||||
if (!op.isPayloadEncrypted) return op;
|
||||
const decryptedStr = await decrypt(op.payload, encryptKey);
|
||||
return { ...op, payload: JSON.parse(decryptedStr), isPayloadEncrypted: false };
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Encryption Algorithm
|
||||
|
||||
**Location**: `src/app/pfapi/api/encryption/encryption.ts`
|
||||
|
||||
- **Algorithm**: AES-256-GCM (Galois/Counter Mode)
|
||||
- **Key Derivation**: Argon2id (memory-hard, resistant to GPU attacks)
|
||||
- **Salt**: Random 16 bytes per encryption
|
||||
- **IV**: Random 12 bytes per encryption
|
||||
- **Output Format**: `salt || iv || ciphertext || authTag` (base64 encoded)
|
||||
|
||||
### 3. Upload Integration
|
||||
|
||||
**Location**: `src/app/core/persistence/operation-log/sync/operation-log-upload.service.ts`
|
||||
|
||||
```typescript
|
||||
// Check if encryption is enabled
|
||||
const privateCfg = await syncProvider.privateCfg.load();
|
||||
const isEncryptionEnabled = privateCfg?.isEncryptionEnabled && !!privateCfg?.encryptKey;
|
||||
|
||||
// Encrypt if enabled
|
||||
if (isEncryptionEnabled && encryptKey) {
|
||||
syncOps = await this.encryptionService.encryptOperations(syncOps, encryptKey);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Download Integration
|
||||
|
||||
**Location**: `src/app/core/persistence/operation-log/sync/operation-log-download.service.ts`
|
||||
|
||||
```typescript
|
||||
// Decrypt if encrypted
|
||||
const hasEncryptedOps = ops.some((op) => op.isPayloadEncrypted);
|
||||
if (hasEncryptedOps && encryptKey) {
|
||||
ops = await this.encryptionService.decryptOperations(ops, encryptKey);
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Storage
|
||||
|
||||
The encryption password is stored in the **private config** (not synced):
|
||||
|
||||
```
|
||||
privateCfg: {
|
||||
isEncryptionEnabled: true,
|
||||
encryptKey: "user's password" // Stored locally, never sent to server
|
||||
}
|
||||
```
|
||||
|
||||
## Security Properties
|
||||
|
||||
| Property | Guarantee |
|
||||
| ------------------- | ------------------------------------- |
|
||||
| **Confidentiality** | Server cannot read operation payloads |
|
||||
| **Integrity** | GCM auth tag detects tampering |
|
||||
| **Key Security** | Argon2id makes brute-force expensive |
|
||||
| **Forward Secrecy** | Each operation uses random IV |
|
||||
| **Wrong Password** | Decryption fails, operation rejected |
|
||||
|
||||
## Wrong Password Handling
|
||||
|
||||
```
|
||||
Client C (wrong password) tries to sync:
|
||||
│
|
||||
▼
|
||||
Download encrypted ops
|
||||
│
|
||||
▼
|
||||
Attempt decryption with wrong key
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ DecryptError thrown │
|
||||
│ "Failed to decrypt payload"│
|
||||
└─────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
Operation NOT applied to state
|
||||
Sync error shown in UI
|
||||
```
|
||||
|
||||
## Snapshot Encryption
|
||||
|
||||
Full-state operations (backup import, repair) use the snapshot endpoint but follow the same encryption:
|
||||
|
||||
```typescript
|
||||
// In operation-log-upload.service.ts
|
||||
if (encryptKey) {
|
||||
state = await this.encryptionService.encryptPayload(state, encryptKey);
|
||||
}
|
||||
await syncProvider.uploadSnapshot(
|
||||
state,
|
||||
clientId,
|
||||
reason,
|
||||
vectorClock,
|
||||
schemaVersion,
|
||||
isPayloadEncrypted,
|
||||
);
|
||||
```
|
||||
|
|
@ -390,4 +390,62 @@ export class SuperSyncPage extends BasePage {
|
|||
// Allow UI to settle after sync - reduces flakiness
|
||||
await this.page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the sync settings dialog and change the encryption password.
|
||||
* This will delete all server data and re-upload with the new password.
|
||||
*
|
||||
* @param newPassword - The new encryption password
|
||||
*/
|
||||
async changeEncryptionPassword(newPassword: string): Promise<void> {
|
||||
// Open sync settings via right-click
|
||||
await this.syncBtn.click({ button: 'right' });
|
||||
await this.providerSelect.waitFor({ state: 'visible', timeout: 10000 });
|
||||
|
||||
// Scroll down to find the change password button
|
||||
const dialogContent = this.page.locator('mat-dialog-content');
|
||||
await dialogContent.evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
|
||||
// Click the "Change Encryption Password" button
|
||||
const changePasswordBtn = this.page.locator(
|
||||
'button:has-text("Change Encryption Password")',
|
||||
);
|
||||
await changePasswordBtn.waitFor({ state: 'visible', timeout: 5000 });
|
||||
await changePasswordBtn.click();
|
||||
|
||||
// Wait for the change password dialog to appear
|
||||
const changePasswordDialog = this.page.locator('dialog-change-encryption-password');
|
||||
await changePasswordDialog.waitFor({ state: 'visible', timeout: 5000 });
|
||||
|
||||
// Fill in the new password
|
||||
const newPasswordInput = changePasswordDialog.locator('input[name="newPassword"]');
|
||||
const confirmPasswordInput = changePasswordDialog.locator(
|
||||
'input[name="confirmPassword"]',
|
||||
);
|
||||
|
||||
await newPasswordInput.fill(newPassword);
|
||||
await confirmPasswordInput.fill(newPassword);
|
||||
|
||||
// Click the confirm button
|
||||
const confirmBtn = changePasswordDialog.locator('button[color="warn"]');
|
||||
await confirmBtn.click();
|
||||
|
||||
// Wait for the dialog to close (password change complete)
|
||||
await changePasswordDialog.waitFor({ state: 'detached', timeout: 60000 });
|
||||
|
||||
// Verify success snackbar
|
||||
const snackbar = this.page.locator('simple-snack-bar');
|
||||
await snackbar.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {});
|
||||
const snackbarText = await snackbar.textContent().catch(() => '');
|
||||
if (snackbarText?.toLowerCase().includes('error')) {
|
||||
throw new Error(`Password change failed: ${snackbarText}`);
|
||||
}
|
||||
|
||||
// Close the sync settings dialog if still open
|
||||
const dialogContainer = this.page.locator('mat-dialog-container');
|
||||
if (await dialogContainer.isVisible()) {
|
||||
await this.page.keyboard.press('Escape');
|
||||
await dialogContainer.waitFor({ state: 'detached', timeout: 5000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
357
e2e/tests/sync/supersync-encryption-password-change.spec.ts
Normal file
357
e2e/tests/sync/supersync-encryption-password-change.spec.ts
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
import { test as base, expect } from '@playwright/test';
|
||||
import {
|
||||
createTestUser,
|
||||
getSuperSyncConfig,
|
||||
createSimulatedClient,
|
||||
closeClient,
|
||||
waitForTask,
|
||||
isServerHealthy,
|
||||
type SimulatedE2EClient,
|
||||
} from '../../utils/supersync-helpers';
|
||||
|
||||
/**
|
||||
* SuperSync Encryption Password Change E2E Tests
|
||||
*
|
||||
* Verifies the encryption password change flow:
|
||||
* - Password change deletes server data and re-uploads with new password
|
||||
* - Existing data is preserved after password change
|
||||
* - Other clients must use the new password to sync
|
||||
* - Old password no longer works after change
|
||||
*
|
||||
* Run with E2E_VERBOSE=1 to see browser console logs for debugging.
|
||||
*/
|
||||
|
||||
const generateTestRunId = (workerIndex: number): string => {
|
||||
return `${Date.now()}-${workerIndex}`;
|
||||
};
|
||||
|
||||
base.describe('@supersync SuperSync Encryption Password Change', () => {
|
||||
let serverHealthy: boolean | null = null;
|
||||
|
||||
base.beforeEach(async ({}, testInfo) => {
|
||||
if (serverHealthy === null) {
|
||||
serverHealthy = await isServerHealthy();
|
||||
if (!serverHealthy) {
|
||||
console.warn(
|
||||
'SuperSync server not healthy at http://localhost:1901 - skipping tests',
|
||||
);
|
||||
}
|
||||
}
|
||||
testInfo.skip(!serverHealthy, 'SuperSync server not running');
|
||||
});
|
||||
|
||||
base(
|
||||
'Password change preserves existing data',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
|
||||
// --- Setup with initial password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
// Create tasks
|
||||
const task1 = `PreChange-${testRunId}`;
|
||||
const task2 = `AlsoPreChange-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.page.waitForTimeout(100);
|
||||
await clientA.workView.addTask(task2);
|
||||
|
||||
// Sync with old password
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Verify tasks exist
|
||||
await waitForTask(clientA.page, task1);
|
||||
await waitForTask(clientA.page, task2);
|
||||
|
||||
// --- Change password ---
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// --- Verify tasks still exist after password change ---
|
||||
await expect(clientA.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
|
||||
// Trigger another sync to verify everything works
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Tasks should still be there
|
||||
await expect(clientA.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
base(
|
||||
'New client can sync with new password after password change',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
|
||||
// --- Client A: Setup and create data ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
const taskName = `BeforeChange-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Client A: Change password ---
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// --- Client B: Setup with NEW password ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: newPassword,
|
||||
});
|
||||
|
||||
// Sync should succeed with new password
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify task synced to Client B
|
||||
await waitForTask(clientB.page, taskName);
|
||||
await expect(clientB.page.locator(`task:has-text("${taskName}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
base(
|
||||
'Old password fails after password change',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientC: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
|
||||
// --- Client A: Setup, create data, and change password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
const taskName = `SecretTask-${testRunId}`;
|
||||
await clientA.workView.addTask(taskName);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// Change password
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// --- Client C: Try to sync with OLD password ---
|
||||
clientC = await createSimulatedClient(browser, baseURL!, 'C', testRunId);
|
||||
await clientC.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword, // Using OLD password!
|
||||
});
|
||||
|
||||
// Try to sync - should fail or not get the data
|
||||
try {
|
||||
await clientC.sync.triggerSync();
|
||||
await clientC.sync.waitForSyncComplete();
|
||||
} catch (e) {
|
||||
// Expected - sync may throw an error
|
||||
console.log('Sync with old password failed as expected:', e);
|
||||
}
|
||||
|
||||
// Verify Client C does NOT have the task
|
||||
await expect(
|
||||
clientC.page.locator(`task:has-text("${taskName}")`),
|
||||
).not.toBeVisible();
|
||||
|
||||
// Check for error state
|
||||
const hasError = await clientC.sync.hasSyncError();
|
||||
const snackbar = clientC.page.locator('simple-snack-bar');
|
||||
const snackbarVisible = await snackbar.isVisible().catch(() => false);
|
||||
|
||||
// Either error icon or error snackbar should be visible
|
||||
if (!hasError && !snackbarVisible) {
|
||||
// If no visible error, at least verify no data was synced
|
||||
const taskCount = await clientC.page.locator('task').count();
|
||||
console.log(`Client C has ${taskCount} tasks (should be 0 real tasks)`);
|
||||
}
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientC) await closeClient(clientC);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
base(
|
||||
'Bidirectional sync works after password change',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const oldPassword = `oldpass-${testRunId}`;
|
||||
const newPassword = `newpass-${testRunId}`;
|
||||
|
||||
// --- Setup both clients with old password ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
// --- Create initial tasks and sync ---
|
||||
const taskFromA = `FromA-${testRunId}`;
|
||||
await clientA.workView.addTask(taskFromA);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
await clientB.sync.syncAndWait();
|
||||
await waitForTask(clientB.page, taskFromA);
|
||||
|
||||
// --- Client A changes password ---
|
||||
await clientA.sync.changeEncryptionPassword(newPassword);
|
||||
|
||||
// --- Client B must reconfigure with new password ---
|
||||
// Close and recreate with new password (simulating user entering new password)
|
||||
await closeClient(clientB);
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B2', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: newPassword,
|
||||
});
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// Verify B has the task
|
||||
await waitForTask(clientB.page, taskFromA);
|
||||
|
||||
// --- Client B creates a new task ---
|
||||
const taskFromB = `FromB-${testRunId}`;
|
||||
await clientB.workView.addTask(taskFromB);
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
// --- Client A syncs and should get B's task ---
|
||||
await clientA.sync.syncAndWait();
|
||||
await waitForTask(clientA.page, taskFromB);
|
||||
|
||||
// Verify both clients have both tasks
|
||||
await expect(clientA.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${taskFromA}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${taskFromB}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
base(
|
||||
'Multiple password changes work correctly',
|
||||
async ({ browser, baseURL }, testInfo) => {
|
||||
const testRunId = generateTestRunId(testInfo.workerIndex);
|
||||
let clientA: SimulatedE2EClient | null = null;
|
||||
let clientB: SimulatedE2EClient | null = null;
|
||||
|
||||
try {
|
||||
const user = await createTestUser(testRunId);
|
||||
const baseConfig = getSuperSyncConfig(user);
|
||||
const password1 = `pass1-${testRunId}`;
|
||||
const password2 = `pass2-${testRunId}`;
|
||||
const password3 = `pass3-${testRunId}`;
|
||||
|
||||
// --- Setup with password1 ---
|
||||
clientA = await createSimulatedClient(browser, baseURL!, 'A', testRunId);
|
||||
await clientA.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: password1,
|
||||
});
|
||||
|
||||
// Create and sync task1
|
||||
const task1 = `Task1-${testRunId}`;
|
||||
await clientA.workView.addTask(task1);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Change to password2 ---
|
||||
await clientA.sync.changeEncryptionPassword(password2);
|
||||
|
||||
// Create and sync task2
|
||||
const task2 = `Task2-${testRunId}`;
|
||||
await clientA.workView.addTask(task2);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Change to password3 ---
|
||||
await clientA.sync.changeEncryptionPassword(password3);
|
||||
|
||||
// Create and sync task3
|
||||
const task3 = `Task3-${testRunId}`;
|
||||
await clientA.workView.addTask(task3);
|
||||
await clientA.sync.syncAndWait();
|
||||
|
||||
// --- Verify all tasks still exist ---
|
||||
await expect(clientA.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
await expect(clientA.page.locator(`task:has-text("${task3}")`)).toBeVisible();
|
||||
|
||||
// --- New client with password3 should see all tasks ---
|
||||
clientB = await createSimulatedClient(browser, baseURL!, 'B', testRunId);
|
||||
await clientB.sync.setupSuperSync({
|
||||
...baseConfig,
|
||||
isEncryptionEnabled: true,
|
||||
password: password3,
|
||||
});
|
||||
await clientB.sync.syncAndWait();
|
||||
|
||||
await waitForTask(clientB.page, task1);
|
||||
await waitForTask(clientB.page, task2);
|
||||
await waitForTask(clientB.page, task3);
|
||||
|
||||
await expect(clientB.page.locator(`task:has-text("${task1}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${task2}")`)).toBeVisible();
|
||||
await expect(clientB.page.locator(`task:has-text("${task3}")`)).toBeVisible();
|
||||
} finally {
|
||||
if (clientA) await closeClient(clientA);
|
||||
if (clientB) await closeClient(clientB);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -582,6 +582,29 @@ export const syncRoutes = async (fastify: FastifyInstance): Promise<void> => {
|
|||
}
|
||||
});
|
||||
|
||||
// DELETE /api/sync/data - Delete all sync data for user
|
||||
// Used for encryption password changes
|
||||
fastify.delete('/data', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
const userId = getAuthUser(req).userId;
|
||||
const syncService = getSyncService();
|
||||
|
||||
Logger.info(`[user:${userId}] DELETE ALL DATA requested`);
|
||||
|
||||
await syncService.deleteAllUserData(userId);
|
||||
|
||||
Logger.audit({
|
||||
event: 'USER_DATA_DELETED',
|
||||
userId,
|
||||
});
|
||||
|
||||
return reply.send({ success: true });
|
||||
} catch (err) {
|
||||
Logger.error(`Delete user data error: ${errorMessage(err)}`);
|
||||
return reply.status(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/sync/restore-points - List available restore points
|
||||
fastify.get<{
|
||||
Querystring: { limit?: string };
|
||||
|
|
|
|||
|
|
@ -1524,6 +1524,36 @@ export class SyncService {
|
|||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete ALL sync data for a user. Used for encryption password changes.
|
||||
* Deletes operations, tombstones, devices, and resets sync state.
|
||||
*/
|
||||
async deleteAllUserData(userId: number): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Delete all operations
|
||||
await tx.operation.deleteMany({ where: { userId } });
|
||||
|
||||
// Delete all tombstones
|
||||
await tx.tombstone.deleteMany({ where: { userId } });
|
||||
|
||||
// Delete all devices
|
||||
await tx.syncDevice.deleteMany({ where: { userId } });
|
||||
|
||||
// Reset sync state (delete if exists)
|
||||
await tx.userSyncState.deleteMany({ where: { userId } });
|
||||
|
||||
// Reset storage usage
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { storageUsedBytes: BigInt(0) },
|
||||
});
|
||||
});
|
||||
|
||||
// Clear caches
|
||||
this.rateLimitCounters.delete(userId);
|
||||
this.snapshotGenerationLocks.delete(userId);
|
||||
}
|
||||
|
||||
async isDeviceOwner(userId: number, clientId: string): Promise<boolean> {
|
||||
const count = await prisma.syncDevice.count({
|
||||
where: { userId, clientId },
|
||||
|
|
|
|||
|
|
@ -151,6 +151,12 @@ class MockOperationSyncProvider
|
|||
return { rev: 'new-rev' };
|
||||
}
|
||||
async removeFile(targetPath: string): Promise<void> {}
|
||||
|
||||
async deleteAllData(): Promise<{ success: boolean }> {
|
||||
this._uploadedOps = [];
|
||||
this._lastServerSeq = 0;
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
describe('Service Logic Integration', () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
<h1 mat-dialog-title>
|
||||
{{ T.F.SYNC.FORM.SUPER_SYNC.L_CHANGE_ENCRYPTION_PASSWORD | translate }}
|
||||
</h1>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="warning-box">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<p>{{ T.F.SYNC.FORM.SUPER_SYNC.CHANGE_PASSWORD_WARNING | translate }}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
#formEl="ngForm"
|
||||
(ngSubmit)="formEl.valid && isValid && confirm()"
|
||||
>
|
||||
<mat-form-field>
|
||||
<mat-label>{{ T.F.SYNC.FORM.SUPER_SYNC.L_NEW_PASSWORD | translate }}</mat-label>
|
||||
<input
|
||||
[(ngModel)]="newPassword"
|
||||
name="newPassword"
|
||||
matInput
|
||||
type="password"
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
@if (newPassword.length > 0 && newPassword.length < 8) {
|
||||
<mat-error>{{
|
||||
T.F.SYNC.FORM.SUPER_SYNC.PASSWORD_MIN_LENGTH | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field>
|
||||
<mat-label>{{ T.F.SYNC.FORM.SUPER_SYNC.L_CONFIRM_PASSWORD | translate }}</mat-label>
|
||||
<input
|
||||
[(ngModel)]="confirmPassword"
|
||||
name="confirmPassword"
|
||||
matInput
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
@if (confirmPassword.length > 0 && !passwordsMatch) {
|
||||
<mat-error>{{
|
||||
T.F.SYNC.FORM.SUPER_SYNC.PASSWORDS_DONT_MATCH | translate
|
||||
}}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="isLoading()"
|
||||
(click)="cancel()"
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="!isValid || isLoading()"
|
||||
(click)="confirm()"
|
||||
>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon>lock_reset</mat-icon>
|
||||
{{ T.F.SYNC.FORM.SUPER_SYNC.BTN_CHANGE_PASSWORD | translate }}
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
.warning-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
|
||||
mat-icon {
|
||||
color: #ff9800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatDialogRef } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import {
|
||||
DialogChangeEncryptionPasswordComponent,
|
||||
ChangeEncryptionPasswordResult,
|
||||
} from './dialog-change-encryption-password.component';
|
||||
import { EncryptionPasswordChangeService } from '../encryption-password-change.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
|
||||
describe('DialogChangeEncryptionPasswordComponent', () => {
|
||||
let component: DialogChangeEncryptionPasswordComponent;
|
||||
let fixture: ComponentFixture<DialogChangeEncryptionPasswordComponent>;
|
||||
let mockDialogRef: jasmine.SpyObj<
|
||||
MatDialogRef<DialogChangeEncryptionPasswordComponent, ChangeEncryptionPasswordResult>
|
||||
>;
|
||||
let mockEncryptionPasswordChangeService: jasmine.SpyObj<EncryptionPasswordChangeService>;
|
||||
let mockSnackService: jasmine.SpyObj<SnackService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDialogRef = jasmine.createSpyObj('MatDialogRef', ['close']);
|
||||
mockEncryptionPasswordChangeService = jasmine.createSpyObj(
|
||||
'EncryptionPasswordChangeService',
|
||||
['changePassword'],
|
||||
);
|
||||
mockSnackService = jasmine.createSpyObj('SnackService', ['open']);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
DialogChangeEncryptionPasswordComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{ provide: MatDialogRef, useValue: mockDialogRef },
|
||||
{
|
||||
provide: EncryptionPasswordChangeService,
|
||||
useValue: mockEncryptionPasswordChangeService,
|
||||
},
|
||||
{ provide: SnackService, useValue: mockSnackService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DialogChangeEncryptionPasswordComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('should be invalid when password is empty', () => {
|
||||
component.newPassword = '';
|
||||
component.confirmPassword = '';
|
||||
expect(component.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should be invalid when password is less than 8 characters', () => {
|
||||
component.newPassword = '1234567';
|
||||
component.confirmPassword = '1234567';
|
||||
expect(component.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should be invalid when passwords do not match', () => {
|
||||
component.newPassword = 'password123';
|
||||
component.confirmPassword = 'password456';
|
||||
expect(component.passwordsMatch).toBe(false);
|
||||
expect(component.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should be valid when password is 8+ characters and passwords match', () => {
|
||||
component.newPassword = 'password123';
|
||||
component.confirmPassword = 'password123';
|
||||
expect(component.passwordsMatch).toBe(true);
|
||||
expect(component.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should be valid with exactly 8 characters', () => {
|
||||
component.newPassword = '12345678';
|
||||
component.confirmPassword = '12345678';
|
||||
expect(component.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should be valid with very long password', () => {
|
||||
const longPassword = 'a'.repeat(100);
|
||||
component.newPassword = longPassword;
|
||||
component.confirmPassword = longPassword;
|
||||
expect(component.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm', () => {
|
||||
it('should do nothing if form is invalid', async () => {
|
||||
component.newPassword = 'short';
|
||||
component.confirmPassword = 'short';
|
||||
|
||||
await component.confirm();
|
||||
|
||||
expect(mockEncryptionPasswordChangeService.changePassword).not.toHaveBeenCalled();
|
||||
expect(mockDialogRef.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing if already loading', async () => {
|
||||
component.newPassword = 'password123';
|
||||
component.confirmPassword = 'password123';
|
||||
component.isLoading.set(true);
|
||||
|
||||
await component.confirm();
|
||||
|
||||
expect(mockEncryptionPasswordChangeService.changePassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set loading state during operation', async () => {
|
||||
component.newPassword = 'password123';
|
||||
component.confirmPassword = 'password123';
|
||||
mockEncryptionPasswordChangeService.changePassword.and.returnValue(
|
||||
new Promise((resolve) => setTimeout(resolve, 100)),
|
||||
);
|
||||
|
||||
const confirmPromise = component.confirm();
|
||||
expect(component.isLoading()).toBe(true);
|
||||
|
||||
await confirmPromise;
|
||||
// After success, dialog closes, loading state may or may not be reset
|
||||
});
|
||||
|
||||
it('should call changePassword and close dialog on success', async () => {
|
||||
component.newPassword = 'password123';
|
||||
component.confirmPassword = 'password123';
|
||||
mockEncryptionPasswordChangeService.changePassword.and.returnValue(
|
||||
Promise.resolve(),
|
||||
);
|
||||
|
||||
await component.confirm();
|
||||
|
||||
expect(mockEncryptionPasswordChangeService.changePassword).toHaveBeenCalledWith(
|
||||
'password123',
|
||||
);
|
||||
expect(mockSnackService.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({ type: 'SUCCESS' }),
|
||||
);
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it('should show error snack and reset loading on failure', async () => {
|
||||
component.newPassword = 'password123';
|
||||
component.confirmPassword = 'password123';
|
||||
mockEncryptionPasswordChangeService.changePassword.and.returnValue(
|
||||
Promise.reject(new Error('Network error')),
|
||||
);
|
||||
|
||||
await component.confirm();
|
||||
|
||||
expect(mockSnackService.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
type: 'ERROR',
|
||||
msg: 'Failed to change password: Network error',
|
||||
}),
|
||||
);
|
||||
expect(component.isLoading()).toBe(false);
|
||||
expect(mockDialogRef.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
component.newPassword = 'password123';
|
||||
component.confirmPassword = 'password123';
|
||||
mockEncryptionPasswordChangeService.changePassword.and.returnValue(
|
||||
Promise.reject('String error'),
|
||||
);
|
||||
|
||||
await component.confirm();
|
||||
|
||||
expect(mockSnackService.open).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
type: 'ERROR',
|
||||
msg: 'Failed to change password: Unknown error',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancel', () => {
|
||||
it('should close dialog with success: false', () => {
|
||||
component.cancel();
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalledWith({ success: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
} from '@angular/material/dialog';
|
||||
import { T } from '../../../t.const';
|
||||
import { MatFormField, MatLabel, MatError } from '@angular/material/form-field';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EncryptionPasswordChangeService } from '../encryption-password-change.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
|
||||
export interface ChangeEncryptionPasswordResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-change-encryption-password',
|
||||
templateUrl: './dialog-change-encryption-password.component.html',
|
||||
styleUrls: ['./dialog-change-encryption-password.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatError,
|
||||
MatInput,
|
||||
FormsModule,
|
||||
MatDialogActions,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
TranslatePipe,
|
||||
MatProgressSpinner,
|
||||
],
|
||||
})
|
||||
export class DialogChangeEncryptionPasswordComponent {
|
||||
private _encryptionPasswordChangeService = inject(EncryptionPasswordChangeService);
|
||||
private _snackService = inject(SnackService);
|
||||
private _matDialogRef =
|
||||
inject<
|
||||
MatDialogRef<
|
||||
DialogChangeEncryptionPasswordComponent,
|
||||
ChangeEncryptionPasswordResult
|
||||
>
|
||||
>(MatDialogRef);
|
||||
|
||||
T: typeof T = T;
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
isLoading = signal(false);
|
||||
|
||||
get passwordsMatch(): boolean {
|
||||
return this.newPassword === this.confirmPassword;
|
||||
}
|
||||
|
||||
get isValid(): boolean {
|
||||
return this.newPassword.length >= 8 && this.passwordsMatch;
|
||||
}
|
||||
|
||||
async confirm(): Promise<void> {
|
||||
if (!this.isValid || this.isLoading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading.set(true);
|
||||
|
||||
try {
|
||||
await this._encryptionPasswordChangeService.changePassword(this.newPassword);
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: T.F.SYNC.FORM.SUPER_SYNC.CHANGE_PASSWORD_SUCCESS,
|
||||
});
|
||||
this._matDialogRef.close({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: `Failed to change password: ${message}`,
|
||||
});
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this._matDialogRef.close({ success: false });
|
||||
}
|
||||
}
|
||||
237
src/app/imex/sync/encryption-password-change.service.spec.ts
Normal file
237
src/app/imex/sync/encryption-password-change.service.spec.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { EncryptionPasswordChangeService } from './encryption-password-change.service';
|
||||
import { PfapiService } from '../../pfapi/pfapi.service';
|
||||
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
|
||||
import { OperationEncryptionService } from '../../core/persistence/operation-log/sync/operation-encryption.service';
|
||||
import { VectorClockService } from '../../core/persistence/operation-log/sync/vector-clock.service';
|
||||
import { CLIENT_ID_PROVIDER } from '../../core/persistence/operation-log/client-id.provider';
|
||||
import { SyncProviderId } from '../../pfapi/api/pfapi.const';
|
||||
|
||||
describe('EncryptionPasswordChangeService', () => {
|
||||
let service: EncryptionPasswordChangeService;
|
||||
let mockPfapiService: jasmine.SpyObj<any>;
|
||||
let mockStoreDelegateService: jasmine.SpyObj<PfapiStoreDelegateService>;
|
||||
let mockEncryptionService: jasmine.SpyObj<OperationEncryptionService>;
|
||||
let mockVectorClockService: jasmine.SpyObj<VectorClockService>;
|
||||
let mockClientIdProvider: jasmine.SpyObj<any>;
|
||||
let mockSyncProvider: jasmine.SpyObj<any>;
|
||||
|
||||
const TEST_PASSWORD = 'new-secure-password-123';
|
||||
const TEST_CLIENT_ID = 'test-client-id-abc';
|
||||
const TEST_VECTOR_CLOCK = { client1: 5, client2: 3 };
|
||||
// Use any to avoid complex type requirements for AllSyncModels
|
||||
const TEST_CURRENT_STATE: any = {
|
||||
task: { ids: ['task1'], entities: { task1: { id: 'task1', title: 'Test Task' } } },
|
||||
project: {
|
||||
ids: ['proj1'],
|
||||
entities: { proj1: { id: 'proj1', title: 'Test Project' } },
|
||||
},
|
||||
};
|
||||
const TEST_ENCRYPTED_STATE = 'encrypted-state-base64-string';
|
||||
|
||||
beforeEach(() => {
|
||||
// Create mock sync provider
|
||||
mockSyncProvider = jasmine.createSpyObj('SyncProvider', [
|
||||
'deleteAllData',
|
||||
'uploadSnapshot',
|
||||
'setPrivateCfg',
|
||||
'setLastServerSeq',
|
||||
]);
|
||||
mockSyncProvider.id = SyncProviderId.SuperSync;
|
||||
mockSyncProvider.supportsOperationSync = true;
|
||||
mockSyncProvider.privateCfg = {
|
||||
load: jasmine.createSpy('load').and.returnValue(
|
||||
Promise.resolve({
|
||||
encryptKey: 'old-password',
|
||||
isEncryptionEnabled: true,
|
||||
}),
|
||||
),
|
||||
};
|
||||
mockSyncProvider.deleteAllData.and.returnValue(Promise.resolve({ success: true }));
|
||||
mockSyncProvider.uploadSnapshot.and.returnValue(
|
||||
Promise.resolve({ accepted: true, serverSeq: 42 }),
|
||||
);
|
||||
mockSyncProvider.setPrivateCfg.and.returnValue(Promise.resolve());
|
||||
mockSyncProvider.setLastServerSeq.and.returnValue(Promise.resolve());
|
||||
|
||||
// Create mock PfapiService
|
||||
mockPfapiService = {
|
||||
pf: {
|
||||
getActiveSyncProvider: jasmine
|
||||
.createSpy('getActiveSyncProvider')
|
||||
.and.returnValue(mockSyncProvider),
|
||||
},
|
||||
};
|
||||
|
||||
mockStoreDelegateService = jasmine.createSpyObj('PfapiStoreDelegateService', [
|
||||
'getAllSyncModelDataFromStore',
|
||||
]);
|
||||
mockStoreDelegateService.getAllSyncModelDataFromStore.and.returnValue(
|
||||
Promise.resolve(TEST_CURRENT_STATE),
|
||||
);
|
||||
|
||||
mockEncryptionService = jasmine.createSpyObj('OperationEncryptionService', [
|
||||
'encryptPayload',
|
||||
]);
|
||||
mockEncryptionService.encryptPayload.and.returnValue(
|
||||
Promise.resolve(TEST_ENCRYPTED_STATE),
|
||||
);
|
||||
|
||||
mockVectorClockService = jasmine.createSpyObj('VectorClockService', [
|
||||
'getCurrentVectorClock',
|
||||
]);
|
||||
mockVectorClockService.getCurrentVectorClock.and.returnValue(
|
||||
Promise.resolve(TEST_VECTOR_CLOCK),
|
||||
);
|
||||
|
||||
mockClientIdProvider = {
|
||||
loadClientId: jasmine
|
||||
.createSpy('loadClientId')
|
||||
.and.returnValue(Promise.resolve(TEST_CLIENT_ID)),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
EncryptionPasswordChangeService,
|
||||
{ provide: PfapiService, useValue: mockPfapiService },
|
||||
{ provide: PfapiStoreDelegateService, useValue: mockStoreDelegateService },
|
||||
{ provide: OperationEncryptionService, useValue: mockEncryptionService },
|
||||
{ provide: VectorClockService, useValue: mockVectorClockService },
|
||||
{ provide: CLIENT_ID_PROVIDER, useValue: mockClientIdProvider },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(EncryptionPasswordChangeService);
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('should successfully change the encryption password', async () => {
|
||||
await service.changePassword(TEST_PASSWORD);
|
||||
|
||||
// Verify correct sequence of operations
|
||||
expect(mockStoreDelegateService.getAllSyncModelDataFromStore).toHaveBeenCalled();
|
||||
expect(mockVectorClockService.getCurrentVectorClock).toHaveBeenCalled();
|
||||
expect(mockClientIdProvider.loadClientId).toHaveBeenCalled();
|
||||
expect(mockSyncProvider.deleteAllData).toHaveBeenCalled();
|
||||
expect(mockEncryptionService.encryptPayload).toHaveBeenCalledWith(
|
||||
TEST_CURRENT_STATE,
|
||||
TEST_PASSWORD,
|
||||
);
|
||||
expect(mockSyncProvider.uploadSnapshot).toHaveBeenCalledWith(
|
||||
TEST_ENCRYPTED_STATE,
|
||||
TEST_CLIENT_ID,
|
||||
'recovery',
|
||||
TEST_VECTOR_CLOCK,
|
||||
jasmine.any(Number), // CURRENT_SCHEMA_VERSION
|
||||
true, // isPayloadEncrypted
|
||||
);
|
||||
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
encryptKey: TEST_PASSWORD,
|
||||
isEncryptionEnabled: true,
|
||||
}),
|
||||
);
|
||||
expect(mockSyncProvider.setLastServerSeq).toHaveBeenCalledWith(42);
|
||||
});
|
||||
|
||||
it('should throw error if sync provider is not SuperSync', async () => {
|
||||
mockSyncProvider.id = 'WebDAV' as SyncProviderId;
|
||||
|
||||
await expectAsync(service.changePassword(TEST_PASSWORD)).toBeRejectedWithError(
|
||||
'Password change is only supported for SuperSync',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if no sync provider is active', async () => {
|
||||
mockPfapiService.pf.getActiveSyncProvider.and.returnValue(null);
|
||||
|
||||
await expectAsync(service.changePassword(TEST_PASSWORD)).toBeRejectedWithError(
|
||||
'Password change is only supported for SuperSync',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if sync provider is not operation sync capable', async () => {
|
||||
mockSyncProvider.supportsOperationSync = false;
|
||||
|
||||
await expectAsync(service.changePassword(TEST_PASSWORD)).toBeRejectedWithError(
|
||||
'Sync provider does not support operation sync',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if client ID is not available', async () => {
|
||||
mockClientIdProvider.loadClientId.and.returnValue(Promise.resolve(null));
|
||||
|
||||
await expectAsync(service.changePassword(TEST_PASSWORD)).toBeRejectedWithError(
|
||||
'Client ID not available',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if snapshot upload fails', async () => {
|
||||
mockSyncProvider.uploadSnapshot.and.returnValue(
|
||||
Promise.resolve({ accepted: false, error: 'Server rejected snapshot' }),
|
||||
);
|
||||
|
||||
await expectAsync(service.changePassword(TEST_PASSWORD)).toBeRejectedWithError(
|
||||
'Snapshot upload failed: Server rejected snapshot',
|
||||
);
|
||||
});
|
||||
|
||||
it('should preserve existing config when updating password', async () => {
|
||||
mockSyncProvider.privateCfg.load.and.returnValue(
|
||||
Promise.resolve({
|
||||
encryptKey: 'old-password',
|
||||
isEncryptionEnabled: true,
|
||||
someOtherSetting: 'preserved-value',
|
||||
}),
|
||||
);
|
||||
|
||||
await service.changePassword(TEST_PASSWORD);
|
||||
|
||||
expect(mockSyncProvider.setPrivateCfg).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
encryptKey: TEST_PASSWORD,
|
||||
isEncryptionEnabled: true,
|
||||
someOtherSetting: 'preserved-value',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call setLastServerSeq if serverSeq is undefined', async () => {
|
||||
mockSyncProvider.uploadSnapshot.and.returnValue(
|
||||
Promise.resolve({ accepted: true }),
|
||||
);
|
||||
|
||||
await service.changePassword(TEST_PASSWORD);
|
||||
|
||||
expect(mockSyncProvider.setLastServerSeq).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete all data before uploading new snapshot', async () => {
|
||||
const callOrder: string[] = [];
|
||||
mockSyncProvider.deleteAllData.and.callFake(() => {
|
||||
callOrder.push('deleteAllData');
|
||||
return Promise.resolve({ success: true });
|
||||
});
|
||||
mockSyncProvider.uploadSnapshot.and.callFake(() => {
|
||||
callOrder.push('uploadSnapshot');
|
||||
return Promise.resolve({ accepted: true, serverSeq: 42 });
|
||||
});
|
||||
|
||||
await service.changePassword(TEST_PASSWORD);
|
||||
|
||||
expect(callOrder).toEqual(['deleteAllData', 'uploadSnapshot']);
|
||||
});
|
||||
|
||||
it('should handle deleteAllData failure gracefully', async () => {
|
||||
mockSyncProvider.deleteAllData.and.returnValue(
|
||||
Promise.reject(new Error('Network error')),
|
||||
);
|
||||
|
||||
await expectAsync(service.changePassword(TEST_PASSWORD)).toBeRejectedWithError(
|
||||
'Network error',
|
||||
);
|
||||
|
||||
// Should not proceed to upload
|
||||
expect(mockSyncProvider.uploadSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
106
src/app/imex/sync/encryption-password-change.service.ts
Normal file
106
src/app/imex/sync/encryption-password-change.service.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { PfapiService } from '../../pfapi/pfapi.service';
|
||||
import { PfapiStoreDelegateService } from '../../pfapi/pfapi-store-delegate.service';
|
||||
import { OperationEncryptionService } from '../../core/persistence/operation-log/sync/operation-encryption.service';
|
||||
import { VectorClockService } from '../../core/persistence/operation-log/sync/vector-clock.service';
|
||||
import {
|
||||
CLIENT_ID_PROVIDER,
|
||||
ClientIdProvider,
|
||||
} from '../../core/persistence/operation-log/client-id.provider';
|
||||
import { isOperationSyncCapable } from '../../core/persistence/operation-log/sync/operation-sync.util';
|
||||
import { SyncProviderId } from '../../pfapi/api/pfapi.const';
|
||||
import { SuperSyncPrivateCfg } from '../../pfapi/api/sync/providers/super-sync/super-sync.model';
|
||||
import { CURRENT_SCHEMA_VERSION } from '../../core/persistence/operation-log/store/schema-migration.service';
|
||||
import { SyncLog } from '../../core/log';
|
||||
|
||||
/**
|
||||
* Service for changing the encryption password for SuperSync.
|
||||
*
|
||||
* Password change flow:
|
||||
* 1. Delete all data on server
|
||||
* 2. Upload current state as snapshot with new password
|
||||
* 3. Update local config with new password
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EncryptionPasswordChangeService {
|
||||
private _pfapiService = inject(PfapiService);
|
||||
private _storeDelegateService = inject(PfapiStoreDelegateService);
|
||||
private _encryptionService = inject(OperationEncryptionService);
|
||||
private _vectorClockService = inject(VectorClockService);
|
||||
private _clientIdProvider: ClientIdProvider = inject(CLIENT_ID_PROVIDER);
|
||||
|
||||
/**
|
||||
* Changes the encryption password by deleting all server data
|
||||
* and uploading a new encrypted snapshot.
|
||||
*
|
||||
* @param newPassword - The new encryption password
|
||||
* @throws Error if sync provider is not SuperSync or not ready
|
||||
*/
|
||||
async changePassword(newPassword: string): Promise<void> {
|
||||
SyncLog.normal('EncryptionPasswordChangeService: Starting password change...');
|
||||
|
||||
// Get the sync provider
|
||||
const syncProvider = this._pfapiService.pf.getActiveSyncProvider();
|
||||
if (!syncProvider || syncProvider.id !== SyncProviderId.SuperSync) {
|
||||
throw new Error('Password change is only supported for SuperSync');
|
||||
}
|
||||
|
||||
if (!isOperationSyncCapable(syncProvider)) {
|
||||
throw new Error('Sync provider does not support operation sync');
|
||||
}
|
||||
|
||||
// Get current state
|
||||
SyncLog.normal('EncryptionPasswordChangeService: Getting current state...');
|
||||
const currentState = await this._storeDelegateService.getAllSyncModelDataFromStore();
|
||||
const vectorClock = await this._vectorClockService.getCurrentVectorClock();
|
||||
const clientId = await this._clientIdProvider.loadClientId();
|
||||
if (!clientId) {
|
||||
throw new Error('Client ID not available');
|
||||
}
|
||||
|
||||
// Delete all server data
|
||||
SyncLog.normal('EncryptionPasswordChangeService: Deleting server data...');
|
||||
await syncProvider.deleteAllData();
|
||||
|
||||
// Encrypt and upload new snapshot
|
||||
SyncLog.normal(
|
||||
'EncryptionPasswordChangeService: Encrypting and uploading snapshot...',
|
||||
);
|
||||
const encryptedState = await this._encryptionService.encryptPayload(
|
||||
currentState,
|
||||
newPassword,
|
||||
);
|
||||
|
||||
const response = await syncProvider.uploadSnapshot(
|
||||
encryptedState,
|
||||
clientId,
|
||||
'recovery',
|
||||
vectorClock,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
true, // isPayloadEncrypted
|
||||
);
|
||||
|
||||
if (!response.accepted) {
|
||||
throw new Error(`Snapshot upload failed: ${response.error}`);
|
||||
}
|
||||
|
||||
// Update local config with new password
|
||||
SyncLog.normal('EncryptionPasswordChangeService: Updating local config...');
|
||||
const existingCfg =
|
||||
(await syncProvider.privateCfg.load()) as SuperSyncPrivateCfg | null;
|
||||
await syncProvider.setPrivateCfg({
|
||||
...existingCfg,
|
||||
encryptKey: newPassword,
|
||||
isEncryptionEnabled: true,
|
||||
} as SuperSyncPrivateCfg);
|
||||
|
||||
// Update lastServerSeq to the new snapshot's seq
|
||||
if (response.serverSeq !== undefined) {
|
||||
await syncProvider.setLastServerSeq(response.serverSeq);
|
||||
}
|
||||
|
||||
SyncLog.normal('EncryptionPasswordChangeService: Password change complete!');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import {
|
||||
DialogChangeEncryptionPasswordComponent,
|
||||
ChangeEncryptionPasswordResult,
|
||||
} from './dialog-change-encryption-password/dialog-change-encryption-password.component';
|
||||
|
||||
/**
|
||||
* Singleton service to open the encryption password change dialog.
|
||||
* Used by the sync form config which doesn't have direct access to injector.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EncryptionPasswordDialogOpenerService {
|
||||
private _matDialog = inject(MatDialog);
|
||||
|
||||
openChangePasswordDialog(): Promise<ChangeEncryptionPasswordResult | undefined> {
|
||||
const dialogRef = this._matDialog.open(DialogChangeEncryptionPasswordComponent, {
|
||||
width: '400px',
|
||||
disableClose: true,
|
||||
});
|
||||
|
||||
return dialogRef.afterClosed().toPromise();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level reference to the dialog opener service.
|
||||
* Initialized by EncryptionPasswordDialogOpenerInitService.
|
||||
*/
|
||||
let dialogOpenerInstance: EncryptionPasswordDialogOpenerService | null = null;
|
||||
|
||||
/**
|
||||
* Sets the dialog opener instance. Called during app initialization.
|
||||
*/
|
||||
export const setDialogOpenerInstance = (
|
||||
instance: EncryptionPasswordDialogOpenerService,
|
||||
): void => {
|
||||
dialogOpenerInstance = instance;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the encryption password change dialog.
|
||||
* Can be called from form config onClick handlers.
|
||||
*/
|
||||
export const openEncryptionPasswordChangeDialog = (): Promise<
|
||||
ChangeEncryptionPasswordResult | undefined
|
||||
> => {
|
||||
if (!dialogOpenerInstance) {
|
||||
console.error('EncryptionPasswordDialogOpenerService not initialized');
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return dialogOpenerInstance.openChangePasswordDialog();
|
||||
};
|
||||
|
|
@ -51,6 +51,7 @@ import { DialogDisableProfilesConfirmationComponent } from '../../features/user-
|
|||
import { SuperSyncRestoreService } from '../../imex/sync/super-sync-restore.service';
|
||||
import { DialogRestorePointComponent } from '../../imex/sync/dialog-restore-point/dialog-restore-point.component';
|
||||
import { LegacySyncProvider } from '../../imex/sync/legacy-sync-provider.model';
|
||||
import { DialogChangeEncryptionPasswordComponent } from '../../imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component';
|
||||
|
||||
@Component({
|
||||
selector: 'config-page',
|
||||
|
|
@ -115,6 +116,23 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
hideExpression: (m: any) =>
|
||||
!m.isEnabled ||
|
||||
m.syncProvider !== LegacySyncProvider.SuperSync ||
|
||||
!m.superSync?.isEncryptionEnabled,
|
||||
key: '_____',
|
||||
type: 'btn',
|
||||
className: 'mt2 block',
|
||||
templateOptions: {
|
||||
text: T.F.SYNC.FORM.SUPER_SYNC.L_CHANGE_ENCRYPTION_PASSWORD,
|
||||
btnType: 'stroked',
|
||||
required: false,
|
||||
onClick: () => {
|
||||
this._openChangePasswordDialog();
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -319,4 +337,11 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
|
|||
maxWidth: '90vw',
|
||||
});
|
||||
}
|
||||
|
||||
private _openChangePasswordDialog(): void {
|
||||
this._matDialog.open(DialogChangeEncryptionPasswordComponent, {
|
||||
width: '400px',
|
||||
disableClose: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -343,7 +343,7 @@ export class DailySummaryComponent implements OnInit, OnDestroy, AfterViewInit {
|
|||
} catch (error) {
|
||||
Log.error('[DailySummary] Failed during pre-archive operations:', error);
|
||||
this._snackService.open({
|
||||
msg: T.F.SYNC.S.S_FINISH_DAY_SYNC_ERROR,
|
||||
msg: T.F.SYNC.S.FINISH_DAY_SYNC_ERROR,
|
||||
type: 'ERROR',
|
||||
});
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -227,6 +227,23 @@ export class SuperSyncProvider
|
|||
return response;
|
||||
}
|
||||
|
||||
// === Data Management ===
|
||||
|
||||
async deleteAllData(): Promise<{ success: boolean }> {
|
||||
SyncLog.debug(this.logLabel, 'deleteAllData');
|
||||
const cfg = await this._cfgOrError();
|
||||
|
||||
const response = await this._fetchApi<{ success: boolean }>(cfg, '/api/sync/data', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
// Reset local lastServerSeq since all server data is deleted
|
||||
const key = await this._getServerSeqKey();
|
||||
localStorage.removeItem(key);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// === Private Helper Methods ===
|
||||
|
||||
private async _cfgOrError(): Promise<SuperSyncPrivateCfg> {
|
||||
|
|
|
|||
|
|
@ -271,6 +271,13 @@ export interface OperationSyncCapable {
|
|||
schemaVersion: number,
|
||||
isPayloadEncrypted?: boolean,
|
||||
): Promise<SnapshotUploadResponse>;
|
||||
|
||||
/**
|
||||
* Delete all sync data for this user on the server.
|
||||
* Used for encryption password changes - deletes all operations,
|
||||
* tombstones, devices, and resets sync state.
|
||||
*/
|
||||
deleteAllData(): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1104,18 +1104,6 @@ const T = {
|
|||
'F.SYNC.D_CONFLICT.VECTOR_COMPARISON_LOCAL_GREATER',
|
||||
VECTOR_COMPARISON_LOCAL_LESS: 'F.SYNC.D_CONFLICT.VECTOR_COMPARISON_LOCAL_LESS',
|
||||
},
|
||||
D_CONFLICT_RESOLUTION: {
|
||||
INTRO: 'F.SYNC.D_CONFLICT_RESOLUTION.INTRO',
|
||||
LAST_OP: 'F.SYNC.D_CONFLICT_RESOLUTION.LAST_OP',
|
||||
LOCAL: 'F.SYNC.D_CONFLICT_RESOLUTION.LOCAL',
|
||||
OPS_COUNT: 'F.SYNC.D_CONFLICT_RESOLUTION.OPS_COUNT',
|
||||
REMOTE: 'F.SYNC.D_CONFLICT_RESOLUTION.REMOTE',
|
||||
TITLE: 'F.SYNC.D_CONFLICT_RESOLUTION.TITLE',
|
||||
USE_ALL_LOCAL: 'F.SYNC.D_CONFLICT_RESOLUTION.USE_ALL_LOCAL',
|
||||
USE_ALL_REMOTE: 'F.SYNC.D_CONFLICT_RESOLUTION.USE_ALL_REMOTE',
|
||||
USE_LOCAL: 'F.SYNC.D_CONFLICT_RESOLUTION.USE_LOCAL',
|
||||
USE_REMOTE: 'F.SYNC.D_CONFLICT_RESOLUTION.USE_REMOTE',
|
||||
},
|
||||
D_DECRYPT_ERROR: {
|
||||
BTN_OVER_WRITE_REMOTE: 'F.SYNC.D_DECRYPT_ERROR.BTN_OVER_WRITE_REMOTE',
|
||||
CHANGE_PW_AND_DECRYPT: 'F.SYNC.D_DECRYPT_ERROR.CHANGE_PW_AND_DECRYPT',
|
||||
|
|
@ -1193,6 +1181,15 @@ const T = {
|
|||
L_ENABLE_E2E_ENCRYPTION: 'F.SYNC.FORM.SUPER_SYNC.L_ENABLE_E2E_ENCRYPTION',
|
||||
ENCRYPTION_WARNING: 'F.SYNC.FORM.SUPER_SYNC.ENCRYPTION_WARNING',
|
||||
COST_WARNING: 'F.SYNC.FORM.SUPER_SYNC.COST_WARNING',
|
||||
L_CHANGE_ENCRYPTION_PASSWORD:
|
||||
'F.SYNC.FORM.SUPER_SYNC.L_CHANGE_ENCRYPTION_PASSWORD',
|
||||
CHANGE_PASSWORD_WARNING: 'F.SYNC.FORM.SUPER_SYNC.CHANGE_PASSWORD_WARNING',
|
||||
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',
|
||||
},
|
||||
L_ENABLE_COMPRESSION: 'F.SYNC.FORM.L_ENABLE_COMPRESSION',
|
||||
L_ENABLE_ENCRYPTION: 'F.SYNC.FORM.L_ENABLE_ENCRYPTION',
|
||||
|
|
@ -1217,39 +1214,39 @@ const T = {
|
|||
BTN_CONFIGURE: 'F.SYNC.S.BTN_CONFIGURE',
|
||||
BTN_FORCE_OVERWRITE: 'F.SYNC.S.BTN_FORCE_OVERWRITE',
|
||||
CLOCK_DRIFT_WARNING: 'F.SYNC.S.CLOCK_DRIFT_WARNING',
|
||||
ERROR_CORS: 'F.SYNC.S.ERROR_CORS',
|
||||
ERROR_DATA_IS_CURRENTLY_WRITTEN: 'F.SYNC.S.ERROR_DATA_IS_CURRENTLY_WRITTEN',
|
||||
ERROR_FALLBACK_TO_BACKUP: 'F.SYNC.S.ERROR_FALLBACK_TO_BACKUP',
|
||||
ERROR_PERMISSION: 'F.SYNC.S.ERROR_PERMISSION',
|
||||
ERROR_INVALID_DATA: 'F.SYNC.S.ERROR_INVALID_DATA',
|
||||
ERROR_NO_REV: 'F.SYNC.S.ERROR_NO_REV',
|
||||
ERROR_UNABLE_TO_READ_REMOTE_DATA: 'F.SYNC.S.ERROR_UNABLE_TO_READ_REMOTE_DATA',
|
||||
IMPORTING: 'F.SYNC.S.IMPORTING',
|
||||
INCOMPLETE_CFG: 'F.SYNC.S.INCOMPLETE_CFG',
|
||||
INCOMPLETE_OP_SYNC: 'F.SYNC.S.INCOMPLETE_OP_SYNC',
|
||||
TOO_MANY_OPS_TO_DOWNLOAD: 'F.SYNC.S.TOO_MANY_OPS_TO_DOWNLOAD',
|
||||
INITIAL_SYNC_ERROR: 'F.SYNC.S.INITIAL_SYNC_ERROR',
|
||||
SUCCESS_DOWNLOAD: 'F.SYNC.S.SUCCESS_DOWNLOAD',
|
||||
SUCCESS_IMPORT: 'F.SYNC.S.SUCCESS_IMPORT',
|
||||
SUCCESS_VIA_BUTTON: 'F.SYNC.S.SUCCESS_VIA_BUTTON',
|
||||
PERSIST_FAILED: 'F.SYNC.S.PERSIST_FAILED',
|
||||
STORAGE_QUOTA_EXCEEDED: 'F.SYNC.S.STORAGE_QUOTA_EXCEEDED',
|
||||
STORAGE_RECOVERED_AFTER_COMPACTION: 'F.SYNC.S.STORAGE_RECOVERED_AFTER_COMPACTION',
|
||||
HYDRATION_FAILED: 'F.SYNC.S.HYDRATION_FAILED',
|
||||
INTEGRITY_CHECK_FAILED: 'F.SYNC.S.INTEGRITY_CHECK_FAILED',
|
||||
COMPACTION_FAILED: 'F.SYNC.S.COMPACTION_FAILED',
|
||||
CONFLICT_RESOLUTION_FAILED: 'F.SYNC.S.CONFLICT_RESOLUTION_FAILED',
|
||||
CONFLICT_DIALOG_TIMEOUT: 'F.SYNC.S.CONFLICT_DIALOG_TIMEOUT',
|
||||
OPERATION_PERMANENTLY_FAILED: 'F.SYNC.S.OPERATION_PERMANENTLY_FAILED',
|
||||
DATA_REPAIRED: 'F.SYNC.S.DATA_REPAIRED',
|
||||
ERROR_CORS: 'F.SYNC.S.ERROR_CORS',
|
||||
ERROR_DATA_IS_CURRENTLY_WRITTEN: 'F.SYNC.S.ERROR_DATA_IS_CURRENTLY_WRITTEN',
|
||||
ERROR_PERMISSION: 'F.SYNC.S.ERROR_PERMISSION',
|
||||
ERROR_FALLBACK_TO_BACKUP: 'F.SYNC.S.ERROR_FALLBACK_TO_BACKUP',
|
||||
ERROR_INVALID_DATA: 'F.SYNC.S.ERROR_INVALID_DATA',
|
||||
ERROR_NO_REV: 'F.SYNC.S.ERROR_NO_REV',
|
||||
ERROR_UNABLE_TO_READ_REMOTE_DATA: 'F.SYNC.S.ERROR_UNABLE_TO_READ_REMOTE_DATA',
|
||||
HYDRATION_FAILED: 'F.SYNC.S.HYDRATION_FAILED',
|
||||
INTEGRITY_CHECK_FAILED: 'F.SYNC.S.INTEGRITY_CHECK_FAILED',
|
||||
IMPORTING: 'F.SYNC.S.IMPORTING',
|
||||
INCOMPLETE_CFG: 'F.SYNC.S.INCOMPLETE_CFG',
|
||||
INCOMPLETE_OP_SYNC: 'F.SYNC.S.INCOMPLETE_OP_SYNC',
|
||||
TOO_MANY_OPS_TO_DOWNLOAD: 'F.SYNC.S.TOO_MANY_OPS_TO_DOWNLOAD',
|
||||
INITIAL_SYNC_ERROR: 'F.SYNC.S.INITIAL_SYNC_ERROR',
|
||||
INVALID_OPERATION_PAYLOAD: 'F.SYNC.S.INVALID_OPERATION_PAYLOAD',
|
||||
DECRYPTION_FAILED: 'F.SYNC.S.DECRYPTION_FAILED',
|
||||
ENCRYPTION_PASSWORD_REQUIRED: 'F.SYNC.S.ENCRYPTION_PASSWORD_REQUIRED',
|
||||
PERSIST_FAILED: 'F.SYNC.S.PERSIST_FAILED',
|
||||
REMOTE_DATA_TOO_OLD: 'F.SYNC.S.REMOTE_DATA_TOO_OLD',
|
||||
STORAGE_QUOTA_EXCEEDED: 'F.SYNC.S.STORAGE_QUOTA_EXCEEDED',
|
||||
STORAGE_RECOVERED_AFTER_COMPACTION: 'F.SYNC.S.STORAGE_RECOVERED_AFTER_COMPACTION',
|
||||
SUCCESS_DOWNLOAD: 'F.SYNC.S.SUCCESS_DOWNLOAD',
|
||||
SUCCESS_IMPORT: 'F.SYNC.S.SUCCESS_IMPORT',
|
||||
SUCCESS_VIA_BUTTON: 'F.SYNC.S.SUCCESS_VIA_BUTTON',
|
||||
UNKNOWN_ERROR: 'F.SYNC.S.UNKNOWN_ERROR',
|
||||
UPLOAD_ERROR: 'F.SYNC.S.UPLOAD_ERROR',
|
||||
UPLOAD_OPS_REJECTED: 'F.SYNC.S.UPLOAD_OPS_REJECTED',
|
||||
VERSION_TOO_OLD: 'F.SYNC.S.VERSION_TOO_OLD',
|
||||
REMOTE_DATA_TOO_OLD: 'F.SYNC.S.REMOTE_DATA_TOO_OLD',
|
||||
FRESH_CLIENT_SYNC_CANCELLED: 'F.SYNC.S.FRESH_CLIENT_SYNC_CANCELLED',
|
||||
RESTORE_SUCCESS: 'F.SYNC.S.RESTORE_SUCCESS',
|
||||
RESTORE_ERROR: 'F.SYNC.S.RESTORE_ERROR',
|
||||
|
|
@ -1257,7 +1254,7 @@ const T = {
|
|||
REPLAY_LOCAL_OPS_FAILED: 'F.SYNC.S.REPLAY_LOCAL_OPS_FAILED',
|
||||
ARCHIVE_OPERATION_FAILED: 'F.SYNC.S.ARCHIVE_OPERATION_FAILED',
|
||||
LWW_CONFLICTS_AUTO_RESOLVED: 'F.SYNC.S.LWW_CONFLICTS_AUTO_RESOLVED',
|
||||
S_FINISH_DAY_SYNC_ERROR: 'F.SYNC.S.FINISH_DAY_SYNC_ERROR',
|
||||
FINISH_DAY_SYNC_ERROR: 'F.SYNC.S.FINISH_DAY_SYNC_ERROR',
|
||||
SERVER_MIGRATION_VALIDATION_FAILED: 'F.SYNC.S.SERVER_MIGRATION_VALIDATION_FAILED',
|
||||
},
|
||||
D_DATA_REPAIRED: {
|
||||
|
|
@ -1718,18 +1715,18 @@ const T = {
|
|||
UNTRACKED_TIME: 'F.TIME_TRACKING.D_TRACKING_REMINDER.UNTRACKED_TIME',
|
||||
},
|
||||
D_ARCHIVE_COMPRESS: {
|
||||
BTN_COMPRESS: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.BTN_COMPRESS',
|
||||
CONFIRM_MSG: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.CONFIRM_MSG',
|
||||
CONFIRM_TITLE: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.CONFIRM_TITLE',
|
||||
ESTIMATED_SAVINGS: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.ESTIMATED_SAVINGS',
|
||||
INTRO: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.INTRO',
|
||||
ISSUE_FIELDS_TO_CLEAR: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.ISSUE_FIELDS_TO_CLEAR',
|
||||
LOADING: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.LOADING',
|
||||
NO_DATA: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.NO_DATA',
|
||||
NOTES_TO_CLEAR: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.NOTES_TO_CLEAR',
|
||||
SUBTASKS_TO_DELETE: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.SUBTASKS_TO_DELETE',
|
||||
TITLE: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.TITLE',
|
||||
LOADING: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.LOADING',
|
||||
INTRO: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.INTRO',
|
||||
SUBTASKS_TO_DELETE: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.SUBTASKS_TO_DELETE',
|
||||
NOTES_TO_CLEAR: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.NOTES_TO_CLEAR',
|
||||
ISSUE_FIELDS_TO_CLEAR: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.ISSUE_FIELDS_TO_CLEAR',
|
||||
ESTIMATED_SAVINGS: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.ESTIMATED_SAVINGS',
|
||||
WARNING: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.WARNING',
|
||||
CONFIRM_TITLE: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.CONFIRM_TITLE',
|
||||
CONFIRM_MSG: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.CONFIRM_MSG',
|
||||
BTN_COMPRESS: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.BTN_COMPRESS',
|
||||
NO_DATA: 'F.TIME_TRACKING.D_ARCHIVE_COMPRESS.NO_DATA',
|
||||
},
|
||||
},
|
||||
WORKLOG: {
|
||||
|
|
@ -1791,7 +1788,6 @@ const T = {
|
|||
},
|
||||
},
|
||||
FILE_IMEX: {
|
||||
BTN_COMPRESS_ARCHIVE: 'FILE_IMEX.BTN_COMPRESS_ARCHIVE',
|
||||
DIALOG_CONFIRM_URL_IMPORT: {
|
||||
INITIATED_MSG: 'FILE_IMEX.DIALOG_CONFIRM_URL_IMPORT.INITIATED_MSG',
|
||||
SOURCE_URL_DOMAIN: 'FILE_IMEX.DIALOG_CONFIRM_URL_IMPORT.SOURCE_URL_DOMAIN',
|
||||
|
|
@ -1813,10 +1809,12 @@ const T = {
|
|||
S_ERR_NETWORK: 'FILE_IMEX.S_ERR_NETWORK',
|
||||
S_IMPORT_FROM_URL_ERR_DECODE: 'FILE_IMEX.S_IMPORT_FROM_URL_ERR_DECODE',
|
||||
URL_PLACEHOLDER: 'FILE_IMEX.URL_PLACEHOLDER',
|
||||
BTN_COMPRESS_ARCHIVE: 'FILE_IMEX.BTN_COMPRESS_ARCHIVE',
|
||||
},
|
||||
G: {
|
||||
ADD: 'G.ADD',
|
||||
ADVANCED_CFG: 'G.ADVANCED_CFG',
|
||||
APPLY: 'G.APPLY',
|
||||
CANCEL: 'G.CANCEL',
|
||||
CLOSE: 'G.CLOSE',
|
||||
COMPLETE: 'G.COMPLETE',
|
||||
|
|
@ -2239,7 +2237,13 @@ const T = {
|
|||
UNPLAN_ALL_TASKS: 'MH.UNPLAN_ALL_TASKS',
|
||||
WORKLOG: 'MH.WORKLOG',
|
||||
},
|
||||
|
||||
MIGRATE: {
|
||||
C_DOWNLOAD_BACKUP: 'MIGRATE.C_DOWNLOAD_BACKUP',
|
||||
DETECTED_LEGACY: 'MIGRATE.DETECTED_LEGACY',
|
||||
E_MIGRATION_FAILED: 'MIGRATE.E_MIGRATION_FAILED',
|
||||
E_RESTART_FAILED: 'MIGRATE.E_RESTART_FAILED',
|
||||
SUCCESS: 'MIGRATE.SUCCESS',
|
||||
},
|
||||
PDS: {
|
||||
ADD_TASKS_FROM_TODAY: 'PDS.ADD_TASKS_FROM_TODAY',
|
||||
ARCHIVED_TASKS: {
|
||||
|
|
|
|||
|
|
@ -1162,7 +1162,15 @@
|
|||
"ACCESS_TOKEN_DESCRIPTION": "Paste the token you copied from the server login page",
|
||||
"L_ENABLE_E2E_ENCRYPTION": "Enable end-to-end encryption",
|
||||
"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.",
|
||||
"COST_WARNING": "⚠️ Please note that this service is free for now, but will likely cost money in the future."
|
||||
"COST_WARNING": "⚠️ Please note that this service is free for now, but will likely cost money in the future.",
|
||||
"L_CHANGE_ENCRYPTION_PASSWORD": "Change Encryption Password",
|
||||
"CHANGE_PASSWORD_WARNING": "Changing your encryption password will delete ALL sync data on the server and upload your current data with the new password. All other devices will need to enter the new password. This cannot be undone.",
|
||||
"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"
|
||||
},
|
||||
"L_ENABLE_COMPRESSION": "Enable Compression (faster data transfer)",
|
||||
"L_ENABLE_ENCRYPTION": "Enable end-to-end encryption (experimental) – Make your data inaccessible for your sync provider",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue