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:
Johannes Millan 2025-12-22 13:31:19 +01:00
parent c757ff500d
commit c4cc32da29
19 changed files with 1646 additions and 48 deletions

View 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,
);
```

View file

@ -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 });
}
}
}

View 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);
}
},
);
});

View file

@ -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 };

View file

@ -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 },

View file

@ -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', () => {

View file

@ -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>

View file

@ -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;
}

View file

@ -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 });
});
});
});

View file

@ -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 });
}
}

View 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();
});
});
});

View 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!');
}
}

View file

@ -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();
};

View file

@ -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,
});
}
}

View file

@ -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;

View file

@ -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> {

View file

@ -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 }>;
}
/**

View file

@ -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: {

View file

@ -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",