mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
feat(supersync): improve encryption UX with confirmation dialogs
Add two encryption UX improvements for SuperSync: 1. Enable encryption confirmation dialog: When enabling encryption, show a warning dialog explaining that all server data will be deleted and re-uploaded with encryption. On confirm, performs the operation automatically. On cancel, reverts the checkbox. 2. Password prompt dialog for missing encryption key: When receiving encrypted data without a configured password, show a dialog prompting the user to enter their encryption password instead of just showing a snack bar error. On save, stores the password and re-syncs.
This commit is contained in:
parent
1ba4c0ca7f
commit
e2db30cfc2
13 changed files with 685 additions and 16 deletions
|
|
@ -7,7 +7,10 @@ import { IS_ELECTRON } from '../../../app.constants';
|
|||
import { fileSyncDroid, fileSyncElectron } from '../../../op-log/model/model-config';
|
||||
import { FormlyFieldConfig } from '@ngx-formly/core';
|
||||
import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform';
|
||||
import { openDisableEncryptionDialog } from '../../../imex/sync/encryption-password-dialog-opener.service';
|
||||
import {
|
||||
openDisableEncryptionDialog,
|
||||
openEnableEncryptionDialog,
|
||||
} from '../../../imex/sync/encryption-password-dialog-opener.service';
|
||||
|
||||
/**
|
||||
* Creates form fields for WebDAV-based sync providers.
|
||||
|
|
@ -240,14 +243,14 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
},
|
||||
hooks: {
|
||||
onInit: (field: FormlyFieldConfig) => {
|
||||
// Track previous value to detect changes from true to false
|
||||
// Track previous value to detect changes
|
||||
let previousValue = field?.formControl?.value;
|
||||
// Guard to prevent multiple dialogs opening simultaneously
|
||||
let isDialogOpen = false;
|
||||
|
||||
const subscription = field?.formControl?.valueChanges.subscribe(
|
||||
async (newValue) => {
|
||||
// Only intercept when changing from true (enabled) to false (disabled)
|
||||
// Intercept when changing from true (enabled) to false (disabled)
|
||||
if (previousValue === true && newValue === false && !isDialogOpen) {
|
||||
isDialogOpen = true;
|
||||
try {
|
||||
|
|
@ -265,6 +268,41 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
} finally {
|
||||
isDialogOpen = false;
|
||||
}
|
||||
}
|
||||
// Intercept when changing from false (disabled) to true (enabled)
|
||||
else if (
|
||||
previousValue === false &&
|
||||
newValue === true &&
|
||||
!isDialogOpen
|
||||
) {
|
||||
// Get the encryption password from the model
|
||||
// The encryptKey field is a sibling in the same fieldGroup
|
||||
const encryptKey = field?.model?.encryptKey;
|
||||
|
||||
if (!encryptKey) {
|
||||
// No password yet - just update previousValue
|
||||
// The user will need to enter a password and the dialog
|
||||
// will be triggered when they save the form
|
||||
previousValue = newValue;
|
||||
return;
|
||||
}
|
||||
|
||||
isDialogOpen = true;
|
||||
try {
|
||||
// Open confirmation dialog with the encrypt key
|
||||
const result = await openEnableEncryptionDialog(encryptKey);
|
||||
|
||||
if (!result?.success) {
|
||||
// User cancelled - reset to false
|
||||
field?.formControl?.setValue(false, { emitEvent: false });
|
||||
previousValue = false;
|
||||
return;
|
||||
}
|
||||
// User confirmed and encryption was enabled successfully
|
||||
previousValue = newValue;
|
||||
} finally {
|
||||
isDialogOpen = false;
|
||||
}
|
||||
} else if (!isDialogOpen) {
|
||||
previousValue = newValue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
<h1 mat-dialog-title>
|
||||
{{ T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_TITLE | translate }}
|
||||
</h1>
|
||||
|
||||
@if (!canProceed()) {
|
||||
<mat-dialog-content>
|
||||
<div class="info-box">
|
||||
<mat-icon aria-hidden="true">info</mat-icon>
|
||||
<div>
|
||||
<p>{{ errorReason() | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="cancel()"
|
||||
>
|
||||
{{ T.G.CLOSE | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
} @else {
|
||||
<mat-dialog-content>
|
||||
<div class="warning-box">
|
||||
<mat-icon aria-hidden="true">warning</mat-icon>
|
||||
<p>{{ T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_WARNING | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h3>{{ T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_WHAT_HAPPENS | translate }}</h3>
|
||||
<ul>
|
||||
<li>{{ T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_ITEM_1 | translate }}</li>
|
||||
<li>{{ T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_ITEM_2 | translate }}</li>
|
||||
<li>{{ T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_ITEM_3 | translate }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
<mat-icon aria-hidden="true">info</mat-icon>
|
||||
<span>{{ T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_NOTE | translate }}</span>
|
||||
</p>
|
||||
</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]="isLoading()"
|
||||
(click)="confirm()"
|
||||
>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner
|
||||
diameter="20"
|
||||
aria-label="Processing"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<mat-icon aria-hidden="true">lock</mat-icon>
|
||||
{{ T.F.SYNC.FORM.SUPER_SYNC.BTN_ENABLE_ENCRYPTION | translate }}
|
||||
</ng-container>
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: rgba(33, 150, 243, 0.1);
|
||||
border: 1px solid rgba(33, 150, 243, 0.3);
|
||||
border-radius: 4px;
|
||||
|
||||
mat-icon {
|
||||
color: #2196f3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.isDarkTheme) .note {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
MAT_DIALOG_DATA,
|
||||
} from '@angular/material/dialog';
|
||||
import { T } from '../../../t.const';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EncryptionEnableService } from '../encryption-enable.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { SyncProviderManager } from '../../../op-log/sync-providers/provider-manager.service';
|
||||
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
|
||||
|
||||
export interface EnableEncryptionDialogData {
|
||||
encryptKey: string;
|
||||
}
|
||||
|
||||
export interface EnableEncryptionResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-enable-encryption',
|
||||
templateUrl: './dialog-enable-encryption.component.html',
|
||||
styleUrls: ['./dialog-enable-encryption.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
TranslatePipe,
|
||||
MatProgressSpinner,
|
||||
],
|
||||
})
|
||||
export class DialogEnableEncryptionComponent {
|
||||
private _encryptionEnableService = inject(EncryptionEnableService);
|
||||
private _snackService = inject(SnackService);
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
private _data = inject<EnableEncryptionDialogData>(MAT_DIALOG_DATA);
|
||||
private _matDialogRef =
|
||||
inject<MatDialogRef<DialogEnableEncryptionComponent, EnableEncryptionResult>>(
|
||||
MatDialogRef,
|
||||
);
|
||||
|
||||
T: typeof T = T;
|
||||
isLoading = signal(false);
|
||||
canProceed = signal(true);
|
||||
errorReason = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
this._checkPreconditions();
|
||||
}
|
||||
|
||||
private _checkPreconditions(): void {
|
||||
const provider = this._providerManager.getActiveProvider();
|
||||
|
||||
if (!provider) {
|
||||
this.canProceed.set(false);
|
||||
this.errorReason.set(T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_NOT_READY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider.id !== SyncProviderId.SuperSync) {
|
||||
this.canProceed.set(false);
|
||||
this.errorReason.set(T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_SUPERSYNC_ONLY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._data?.encryptKey) {
|
||||
this.canProceed.set(false);
|
||||
this.errorReason.set(T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_PASSWORD_REQUIRED);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async confirm(): Promise<void> {
|
||||
if (this.isLoading() || !this.canProceed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading.set(true);
|
||||
|
||||
try {
|
||||
await this._encryptionEnableService.enableEncryption(this._data.encryptKey);
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: T.F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_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 enable encryption: ${message}`,
|
||||
});
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this._matDialogRef.close({ success: false });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<h1 mat-dialog-title>
|
||||
<mat-icon aria-hidden="true">lock</mat-icon>
|
||||
{{ T.F.SYNC.D_ENTER_PASSWORD.TITLE | translate }}
|
||||
</h1>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="content">
|
||||
<p>{{ T.F.SYNC.D_ENTER_PASSWORD.MESSAGE | translate }}</p>
|
||||
|
||||
<form
|
||||
#formEl="ngForm"
|
||||
(ngSubmit)="formEl.valid && saveAndSync()"
|
||||
>
|
||||
<mat-form-field>
|
||||
<mat-label>{{ T.F.SYNC.D_ENTER_PASSWORD.PASSWORD_LABEL | translate }}</mat-label>
|
||||
<input
|
||||
[(ngModel)]="passwordVal"
|
||||
name="passwordVal"
|
||||
matInput
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="cancel()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="!passwordVal"
|
||||
(click)="saveAndSync()"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
{{ T.F.SYNC.D_ENTER_PASSWORD.BTN_SAVE_AND_SYNC | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
.content {
|
||||
max-width: 400px;
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
:host mat-form-field {
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h1 mat-icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import {
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
} from '@angular/material/dialog';
|
||||
import { T } from '../../../t.const';
|
||||
import { MatFormField, MatLabel } 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 { SyncConfigService } from '../sync-config.service';
|
||||
|
||||
export interface EnterEncryptionPasswordResult {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-enter-encryption-password',
|
||||
templateUrl: './dialog-enter-encryption-password.component.html',
|
||||
styleUrls: ['./dialog-enter-encryption-password.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatFormField,
|
||||
MatLabel,
|
||||
MatInput,
|
||||
FormsModule,
|
||||
MatDialogActions,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class DialogEnterEncryptionPasswordComponent {
|
||||
private _syncConfigService = inject(SyncConfigService);
|
||||
private _matDialogRef =
|
||||
inject<
|
||||
MatDialogRef<DialogEnterEncryptionPasswordComponent, EnterEncryptionPasswordResult>
|
||||
>(MatDialogRef);
|
||||
|
||||
T: typeof T = T;
|
||||
passwordVal: string = '';
|
||||
|
||||
saveAndSync(): void {
|
||||
if (!this.passwordVal) {
|
||||
return;
|
||||
}
|
||||
this._syncConfigService.updateEncryptionPassword(this.passwordVal);
|
||||
this._matDialogRef.close({ password: this.passwordVal });
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this._matDialogRef.close({});
|
||||
}
|
||||
}
|
||||
138
src/app/imex/sync/encryption-enable.service.ts
Normal file
138
src/app/imex/sync/encryption-enable.service.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
||||
import { StateSnapshotService } from '../../op-log/backup/state-snapshot.service';
|
||||
import { VectorClockService } from '../../op-log/sync/vector-clock.service';
|
||||
import {
|
||||
CLIENT_ID_PROVIDER,
|
||||
ClientIdProvider,
|
||||
} from '../../op-log/util/client-id.provider';
|
||||
import { isOperationSyncCapable } from '../../op-log/sync/operation-sync.util';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { CURRENT_SCHEMA_VERSION } from '../../op-log/persistence/schema-migration.service';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { uuidv7 } from '../../util/uuid-v7';
|
||||
|
||||
/**
|
||||
* Service for enabling encryption for SuperSync.
|
||||
*
|
||||
* Enable encryption flow:
|
||||
* 1. Delete all data on server (unencrypted operations can't be mixed with encrypted)
|
||||
* 2. Upload current state as encrypted snapshot
|
||||
* 3. Update local config to enable encryption and set the key
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EncryptionEnableService {
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
private _stateSnapshotService = inject(StateSnapshotService);
|
||||
private _vectorClockService = inject(VectorClockService);
|
||||
private _clientIdProvider: ClientIdProvider = inject(CLIENT_ID_PROVIDER);
|
||||
|
||||
/**
|
||||
* Enables encryption by deleting all server data
|
||||
* and uploading a new encrypted snapshot.
|
||||
*
|
||||
* @param encryptKey The encryption key to use
|
||||
* @throws Error if sync provider is not SuperSync or not ready
|
||||
*/
|
||||
async enableEncryption(encryptKey: string): Promise<void> {
|
||||
SyncLog.normal('EncryptionEnableService: Starting encryption enable...');
|
||||
|
||||
if (!encryptKey) {
|
||||
throw new Error('Encryption key is required');
|
||||
}
|
||||
|
||||
// Get the sync provider
|
||||
const syncProvider = this._providerManager.getActiveProvider();
|
||||
if (!syncProvider) {
|
||||
throw new Error('No active sync provider. Please enable sync first.');
|
||||
}
|
||||
if (syncProvider.id !== SyncProviderId.SuperSync) {
|
||||
throw new Error(
|
||||
`Enable encryption is only supported for SuperSync (current: ${syncProvider.id})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isOperationSyncCapable(syncProvider)) {
|
||||
throw new Error('Sync provider does not support operation sync');
|
||||
}
|
||||
|
||||
// Get current config
|
||||
const existingCfg =
|
||||
(await syncProvider.privateCfg.load()) as SuperSyncPrivateCfg | null;
|
||||
|
||||
// Get current state
|
||||
SyncLog.normal('EncryptionEnableService: Getting current state...');
|
||||
const currentState = this._stateSnapshotService.getStateSnapshot();
|
||||
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 (unencrypted ops can't be mixed with encrypted)
|
||||
SyncLog.normal('EncryptionEnableService: Deleting server data...');
|
||||
await syncProvider.deleteAllData();
|
||||
|
||||
// Update local config first - enable encryption and set the key
|
||||
// This must happen BEFORE upload so the upload uses the new key
|
||||
SyncLog.normal('EncryptionEnableService: Updating local config...');
|
||||
await syncProvider.setPrivateCfg({
|
||||
...existingCfg,
|
||||
encryptKey,
|
||||
isEncryptionEnabled: true,
|
||||
} as SuperSyncPrivateCfg);
|
||||
|
||||
// Upload encrypted snapshot
|
||||
SyncLog.normal('EncryptionEnableService: Uploading encrypted snapshot...');
|
||||
try {
|
||||
const response = await syncProvider.uploadSnapshot(
|
||||
currentState,
|
||||
clientId,
|
||||
'recovery',
|
||||
vectorClock,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
true, // isPayloadEncrypted = true
|
||||
uuidv7(), // opId - server must use this ID
|
||||
);
|
||||
|
||||
if (!response.accepted) {
|
||||
throw new Error(`Snapshot upload failed: ${response.error}`);
|
||||
}
|
||||
|
||||
// Update lastServerSeq to the new snapshot's seq
|
||||
if (response.serverSeq !== undefined) {
|
||||
await syncProvider.setLastServerSeq(response.serverSeq);
|
||||
} else {
|
||||
SyncLog.err(
|
||||
'EncryptionEnableService: Snapshot accepted but serverSeq is missing. ' +
|
||||
'Sync state may be inconsistent - consider using "Sync Now" to verify.',
|
||||
);
|
||||
}
|
||||
|
||||
SyncLog.normal('EncryptionEnableService: Encryption enabled successfully!');
|
||||
} catch (uploadError) {
|
||||
// CRITICAL: Server data was deleted but new snapshot failed to upload.
|
||||
// Try to revert local config to unencrypted state
|
||||
SyncLog.err(
|
||||
'EncryptionEnableService: Snapshot upload failed after deleting server data!',
|
||||
uploadError,
|
||||
);
|
||||
|
||||
// Revert local config
|
||||
await syncProvider.setPrivateCfg({
|
||||
...existingCfg,
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
} as SuperSyncPrivateCfg);
|
||||
|
||||
throw new Error(
|
||||
'CRITICAL: Failed to upload encrypted snapshot after deleting server data. ' +
|
||||
'Your local data is safe. Encryption has been reverted. Please use "Sync Now" to re-upload your data. ' +
|
||||
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,11 @@ import {
|
|||
DialogDisableEncryptionComponent,
|
||||
DisableEncryptionResult,
|
||||
} from './dialog-disable-encryption/dialog-disable-encryption.component';
|
||||
import {
|
||||
DialogEnableEncryptionComponent,
|
||||
EnableEncryptionDialogData,
|
||||
EnableEncryptionResult,
|
||||
} from './dialog-enable-encryption/dialog-enable-encryption.component';
|
||||
|
||||
/**
|
||||
* Singleton service to open the encryption password change dialog.
|
||||
|
|
@ -36,6 +41,18 @@ export class EncryptionPasswordDialogOpenerService {
|
|||
|
||||
return dialogRef.afterClosed().toPromise();
|
||||
}
|
||||
|
||||
openEnableEncryptionDialog(
|
||||
encryptKey: string,
|
||||
): Promise<EnableEncryptionResult | undefined> {
|
||||
const dialogRef = this._matDialog.open(DialogEnableEncryptionComponent, {
|
||||
width: '450px',
|
||||
disableClose: true,
|
||||
data: { encryptKey } as EnableEncryptionDialogData,
|
||||
});
|
||||
|
||||
return dialogRef.afterClosed().toPromise();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,3 +97,17 @@ export const openDisableEncryptionDialog = (): Promise<
|
|||
}
|
||||
return dialogOpenerInstance.openDisableEncryptionDialog();
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the enable encryption confirmation dialog.
|
||||
* Can be called from form config onChange handlers.
|
||||
*/
|
||||
export const openEnableEncryptionDialog = (
|
||||
encryptKey: string,
|
||||
): Promise<EnableEncryptionResult | undefined> => {
|
||||
if (!dialogOpenerInstance) {
|
||||
console.error('EncryptionPasswordDialogOpenerService not initialized');
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return dialogOpenerInstance.openEnableEncryptionDialog(encryptKey);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { DataInitService } from '../../core/data-init/data-init.service';
|
|||
import { DialogSyncInitialCfgComponent } from './dialog-sync-initial-cfg/dialog-sync-initial-cfg.component';
|
||||
import { DialogIncompleteSyncComponent } from './dialog-incomplete-sync/dialog-incomplete-sync.component';
|
||||
import { DialogHandleDecryptErrorComponent } from './dialog-handle-decrypt-error/dialog-handle-decrypt-error.component';
|
||||
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component';
|
||||
import { DialogIncoherentTimestampsErrorComponent } from './dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { promiseTimeout } from '../../util/promise-timeout';
|
||||
|
|
@ -315,10 +316,10 @@ export class SyncWrapperService {
|
|||
actionStr: T.F.SYNC.S.BTN_FORCE_OVERWRITE,
|
||||
});
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (
|
||||
error instanceof DecryptNoPasswordError ||
|
||||
error instanceof DecryptError
|
||||
) {
|
||||
} else if (error instanceof DecryptNoPasswordError) {
|
||||
this._handleMissingPasswordDialog();
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (error instanceof DecryptError) {
|
||||
this._handleDecryptionError();
|
||||
return 'HANDLED_ERROR';
|
||||
} else if (error instanceof CanNotMigrateMajorDownError) {
|
||||
|
|
@ -530,6 +531,33 @@ export class SyncWrapperService {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles missing encryption password when receiving encrypted data.
|
||||
* Opens a simple dialog to prompt for the password, then re-syncs.
|
||||
*/
|
||||
private _handleMissingPasswordDialog(): void {
|
||||
// Set ERROR status so sync button shows error icon
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
|
||||
// Open dialog for password entry
|
||||
this._matDialog
|
||||
.open(DialogEnterEncryptionPasswordComponent, {
|
||||
width: '450px',
|
||||
disableClose: true,
|
||||
autoFocus: false,
|
||||
})
|
||||
.afterClosed()
|
||||
.subscribe((result) => {
|
||||
if (result?.password) {
|
||||
// Password was entered and saved, re-sync
|
||||
this.sync();
|
||||
} else {
|
||||
// User cancelled - set status to unknown
|
||||
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _handleDecryptionError(): void {
|
||||
// Set ERROR status so sync button shows error icon
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
CLOCK_DRIFT_THRESHOLD_MS,
|
||||
} from '../core/operation-log.const';
|
||||
import { OperationEncryptionService } from './operation-encryption.service';
|
||||
import { DecryptError } from '../core/errors/sync-errors';
|
||||
import { DecryptError, DecryptNoPasswordError } from '../core/errors/sync-errors';
|
||||
import { SuperSyncStatusService } from './super-sync-status.service';
|
||||
import { DownloadResult } from '../core/types/sync-results.types';
|
||||
import { CLIENT_ID_PROVIDER } from '../util/client-id.provider';
|
||||
|
|
@ -197,16 +197,13 @@ export class OperationLogDownloadService {
|
|||
const hasEncryptedOps = syncOps.some((op) => op.isPayloadEncrypted);
|
||||
if (hasEncryptedOps) {
|
||||
if (!encryptKey) {
|
||||
// No encryption key available - fail with a helpful message
|
||||
// No encryption key available - throw error to let sync wrapper show password dialog
|
||||
OpLog.error(
|
||||
'OperationLogDownloadService: Received encrypted operations but no encryption key is configured.',
|
||||
);
|
||||
this.snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.SYNC.S.ENCRYPTION_PASSWORD_REQUIRED,
|
||||
});
|
||||
downloadFailed = true;
|
||||
return;
|
||||
throw new DecryptNoPasswordError(
|
||||
'Encrypted data received but no encryption password is configured',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -964,6 +964,12 @@ const T = {
|
|||
P2: 'F.SYNC.D_DECRYPT_ERROR.P2',
|
||||
PASSWORD: 'F.SYNC.D_DECRYPT_ERROR.PASSWORD',
|
||||
},
|
||||
D_ENTER_PASSWORD: {
|
||||
TITLE: 'F.SYNC.D_ENTER_PASSWORD.TITLE',
|
||||
MESSAGE: 'F.SYNC.D_ENTER_PASSWORD.MESSAGE',
|
||||
PASSWORD_LABEL: 'F.SYNC.D_ENTER_PASSWORD.PASSWORD_LABEL',
|
||||
BTN_SAVE_AND_SYNC: 'F.SYNC.D_ENTER_PASSWORD.BTN_SAVE_AND_SYNC',
|
||||
},
|
||||
D_FRESH_CLIENT_CONFIRM: {
|
||||
MESSAGE: 'F.SYNC.D_FRESH_CLIENT_CONFIRM.MESSAGE',
|
||||
OK: 'F.SYNC.D_FRESH_CLIENT_CONFIRM.OK',
|
||||
|
|
@ -1068,6 +1074,22 @@ const T = {
|
|||
'F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_NOT_READY',
|
||||
DISABLE_ENCRYPTION_SUPERSYNC_ONLY:
|
||||
'F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_SUPERSYNC_ONLY',
|
||||
ENABLE_ENCRYPTION_TITLE: 'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_TITLE',
|
||||
ENABLE_ENCRYPTION_WARNING: 'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_WARNING',
|
||||
ENABLE_ENCRYPTION_WHAT_HAPPENS:
|
||||
'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_WHAT_HAPPENS',
|
||||
ENABLE_ENCRYPTION_ITEM_1: 'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_ITEM_1',
|
||||
ENABLE_ENCRYPTION_ITEM_2: 'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_ITEM_2',
|
||||
ENABLE_ENCRYPTION_ITEM_3: 'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_ITEM_3',
|
||||
ENABLE_ENCRYPTION_NOTE: 'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_NOTE',
|
||||
BTN_ENABLE_ENCRYPTION: 'F.SYNC.FORM.SUPER_SYNC.BTN_ENABLE_ENCRYPTION',
|
||||
ENABLE_ENCRYPTION_SUCCESS: 'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_SUCCESS',
|
||||
ENABLE_ENCRYPTION_NOT_READY:
|
||||
'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_NOT_READY',
|
||||
ENABLE_ENCRYPTION_SUPERSYNC_ONLY:
|
||||
'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_SUPERSYNC_ONLY',
|
||||
ENABLE_ENCRYPTION_PASSWORD_REQUIRED:
|
||||
'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_PASSWORD_REQUIRED',
|
||||
},
|
||||
IMPORT_ENCRYPTION_WARNING: {
|
||||
TITLE: 'F.SYNC.FORM.IMPORT_ENCRYPTION_WARNING.TITLE',
|
||||
|
|
|
|||
|
|
@ -939,6 +939,12 @@
|
|||
"P2": "Or you can also change your password, which will overwrite all remote data.",
|
||||
"PASSWORD": "Password"
|
||||
},
|
||||
"D_ENTER_PASSWORD": {
|
||||
"TITLE": "Encryption Password Required",
|
||||
"MESSAGE": "The sync server contains encrypted data, but no encryption password is configured on this device. Please enter your encryption password to decrypt and sync your data.",
|
||||
"PASSWORD_LABEL": "Encryption Password",
|
||||
"BTN_SAVE_AND_SYNC": "Save & Sync"
|
||||
},
|
||||
"D_FRESH_CLIENT_CONFIRM": {
|
||||
"MESSAGE": "This appears to be a fresh installation. Remote data with {{count}} changes was found. Do you want to download and apply this data?",
|
||||
"OK": "Download Remote Data",
|
||||
|
|
@ -1036,7 +1042,19 @@
|
|||
"DISABLE_ENCRYPTION_SUCCESS": "Encryption disabled successfully",
|
||||
"DISABLE_ENCRYPTION_NOT_READY": "Sync is not enabled or configured.",
|
||||
"DISABLE_ENCRYPTION_SUPERSYNC_ONLY": "This feature is only available for SuperSync.",
|
||||
"DISABLE_ENCRYPTION_ENABLE_FIRST": "Please enable sync and save your SuperSync configuration first, then try again."
|
||||
"DISABLE_ENCRYPTION_ENABLE_FIRST": "Please enable sync and save your SuperSync configuration first, then try again.",
|
||||
"ENABLE_ENCRYPTION_TITLE": "Enable Encryption?",
|
||||
"ENABLE_ENCRYPTION_WARNING": "WARNING: This will permanently delete ALL unencrypted sync data on the server. This is necessary because encrypted operations cannot be mixed with unencrypted ones. If you forget your encryption password, your data cannot be recovered.",
|
||||
"ENABLE_ENCRYPTION_WHAT_HAPPENS": "What will happen:",
|
||||
"ENABLE_ENCRYPTION_ITEM_1": "All sync history and backups on the server will be deleted",
|
||||
"ENABLE_ENCRYPTION_ITEM_2": "Your current data will be re-uploaded with encryption",
|
||||
"ENABLE_ENCRYPTION_ITEM_3": "All other devices will need to enter this encryption password",
|
||||
"ENABLE_ENCRYPTION_NOTE": "Your local data will NOT be deleted. You can disable encryption at any time.",
|
||||
"BTN_ENABLE_ENCRYPTION": "Enable Encryption",
|
||||
"ENABLE_ENCRYPTION_SUCCESS": "Encryption enabled successfully",
|
||||
"ENABLE_ENCRYPTION_NOT_READY": "Sync is not enabled or configured.",
|
||||
"ENABLE_ENCRYPTION_SUPERSYNC_ONLY": "This feature is only available for SuperSync.",
|
||||
"ENABLE_ENCRYPTION_PASSWORD_REQUIRED": "Please enter an encryption password first."
|
||||
},
|
||||
"IMPORT_ENCRYPTION_WARNING": {
|
||||
"TITLE": "Encryption Settings Will Change",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue