mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
feat(sync): add mandatory encryption for SuperSync
Implement end-to-end encryption for SuperSync with the following: - Make encryption mandatory for SuperSync provider (optional for others) - Add encryption setup during initial SuperSync configuration - Add password strength indicator and toggle visibility in dialogs - Probe server for encrypted data before showing encryption dialog - Handle encryption state preservation during file import - Prevent duplicate password dialogs and cascade between clients - Simplify encryption codebase by removing dead code and deduplicating - Add password-strength UI component - Default sync provider to SuperSync in initial setup dialog - Remove legacy dialog components (incomplete-sync, incoherent-timestamps)
This commit is contained in:
parent
90900e3aa1
commit
3ef23354e4
42 changed files with 1227 additions and 1386 deletions
|
|
@ -10,7 +10,6 @@ import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform';
|
|||
import { SUPER_SYNC_DEFAULT_BASE_URL } from '../../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import {
|
||||
closeAllDialogs,
|
||||
openDisableEncryptionDialog,
|
||||
openDisableEncryptionDialogForFileBased,
|
||||
openEnableEncryptionDialog,
|
||||
openEnableEncryptionDialogForFileBased,
|
||||
|
|
@ -308,7 +307,10 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
},
|
||||
},
|
||||
},
|
||||
// Hide disable encryption for SuperSync — encryption is mandatory
|
||||
{
|
||||
hideExpression: (m: any, v: any, field?: FormlyFieldConfig) =>
|
||||
field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync,
|
||||
type: 'btn',
|
||||
className: 'e2e-disable-encryption-btn',
|
||||
templateOptions: {
|
||||
|
|
@ -316,22 +318,16 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
btnType: 'primary',
|
||||
btnStyle: 'stroked',
|
||||
onClick: async (field: FormlyFieldConfig) => {
|
||||
const isSuperSync =
|
||||
field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync;
|
||||
const result = isSuperSync
|
||||
? await openDisableEncryptionDialog()
|
||||
: await openDisableEncryptionDialogForFileBased();
|
||||
const result = await openDisableEncryptionDialogForFileBased();
|
||||
if (
|
||||
result?.success &&
|
||||
result?.encryptionRemoved &&
|
||||
field?.parent?.parent?.model
|
||||
) {
|
||||
field.parent.parent.model.isEncryptionEnabled = false;
|
||||
// Also clear encryptKey if we're in file-based provider context
|
||||
if (!isSuperSync && field?.parent?.parent?.model) {
|
||||
if (field?.parent?.parent?.model) {
|
||||
field.parent.parent.model.encryptKey = '';
|
||||
}
|
||||
// Close the parent settings dialog
|
||||
closeAllDialogs();
|
||||
}
|
||||
return result?.success ? true : false;
|
||||
|
|
@ -415,6 +411,41 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync,
|
||||
},
|
||||
},
|
||||
// Encryption encouragement warning (shown when encryption is NOT enabled)
|
||||
// Hidden during initial setup (encryption dialog opens automatically after save)
|
||||
{
|
||||
hideExpression: (m: any, v: any, field?: FormlyFieldConfig) =>
|
||||
field?.parent?.parent?.model?.syncProvider !== SyncProviderId.SuperSync ||
|
||||
(field?.model?.isEncryptionEnabled ?? false) ||
|
||||
field?.parent?.parent?.model?._isInitialSetup === true,
|
||||
type: 'tpl',
|
||||
templateOptions: {
|
||||
tag: 'p',
|
||||
class: 'encryption-warning',
|
||||
text: T.F.SYNC.FORM.SUPER_SYNC.ENCRYPTION_ENCOURAGED,
|
||||
},
|
||||
},
|
||||
// Enable encryption button for SuperSync (shown when encryption is disabled)
|
||||
// Hidden during initial setup (encryption dialog opens automatically after save)
|
||||
{
|
||||
hideExpression: (m: any, v: any, field?: FormlyFieldConfig) =>
|
||||
field?.parent?.parent?.model?.syncProvider !== SyncProviderId.SuperSync ||
|
||||
(field?.model?.isEncryptionEnabled ?? false) ||
|
||||
field?.parent?.parent?.model?._isInitialSetup === true,
|
||||
type: 'btn',
|
||||
className: 'e2e-enable-encryption-btn',
|
||||
templateOptions: {
|
||||
text: T.F.SYNC.FORM.SUPER_SYNC.BTN_ENABLE_ENCRYPTION,
|
||||
btnType: 'primary',
|
||||
onClick: async (field: FormlyFieldConfig) => {
|
||||
const result = await openEnableEncryptionDialog();
|
||||
if (result?.success && field?.model) {
|
||||
field.model.isEncryptionEnabled = true;
|
||||
}
|
||||
return result?.success ? true : false;
|
||||
},
|
||||
},
|
||||
},
|
||||
// Advanced settings for SuperSync
|
||||
{
|
||||
type: 'collapsible',
|
||||
|
|
@ -422,27 +453,6 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
field?.parent?.parent?.model.syncProvider !== SyncProviderId.SuperSync,
|
||||
props: { label: T.G.ADVANCED_CFG },
|
||||
fieldGroup: [
|
||||
// Enable encryption button for SuperSync (shown when encryption is disabled)
|
||||
{
|
||||
// Note: Using (m, v, field) signature for btn type fields to ensure
|
||||
// hideExpression works correctly with the btn component.
|
||||
// Using ?? false to ensure button stays hidden if field is undefined.
|
||||
hideExpression: (m: any, v: any, field?: FormlyFieldConfig) =>
|
||||
field?.model?.isEncryptionEnabled ?? false,
|
||||
type: 'btn',
|
||||
className: 'e2e-enable-encryption-btn',
|
||||
templateOptions: {
|
||||
text: T.F.SYNC.FORM.SUPER_SYNC.BTN_ENABLE_ENCRYPTION,
|
||||
btnType: 'primary',
|
||||
onClick: async (field: FormlyFieldConfig) => {
|
||||
const result = await openEnableEncryptionDialog();
|
||||
if (result?.success && field?.model) {
|
||||
field.model.isEncryptionEnabled = true;
|
||||
}
|
||||
return result?.success ? true : false;
|
||||
},
|
||||
},
|
||||
},
|
||||
// Server URL
|
||||
{
|
||||
key: 'baseUrl',
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export const globalConfigReducer = createReducer<GlobalConfigState>(
|
|||
// These settings should remain local to each client:
|
||||
// - syncProvider: Each client can use different providers (Dropbox, WebDAV, etc.)
|
||||
// - isEnabled: Each client independently controls whether sync is enabled
|
||||
// - isEncryptionEnabled: Encryption state must not be overwritten by imports
|
||||
//
|
||||
// If oldState.sync.syncProvider is null, we're on first load (using initialGlobalConfigState)
|
||||
// and should use the incoming values (from snapshot). Otherwise, preserve local values.
|
||||
|
|
@ -131,6 +132,10 @@ export const globalConfigReducer = createReducer<GlobalConfigState>(
|
|||
? oldState.sync.isEnabled
|
||||
: incomingSyncConfig.isEnabled;
|
||||
|
||||
const isEncryptionEnabled = hasLocalSettings
|
||||
? oldState.sync.isEncryptionEnabled
|
||||
: incomingSyncConfig.isEncryptionEnabled;
|
||||
|
||||
return {
|
||||
...appDataComplete.globalConfig,
|
||||
// Merge defaults for tasks config to fill missing fields.
|
||||
|
|
@ -152,6 +157,7 @@ export const globalConfigReducer = createReducer<GlobalConfigState>(
|
|||
...incomingSyncConfig,
|
||||
syncProvider,
|
||||
isEnabled,
|
||||
isEncryptionEnabled,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// Shared styles for encryption-related dialogs
|
||||
// Used by: dialog-disable-encryption, dialog-enable-encryption,
|
||||
// dialog-change-encryption-password, dialog-import-encryption-warning
|
||||
// Used by: dialog-enable-encryption, dialog-change-encryption-password,
|
||||
// dialog-import-encryption-warning
|
||||
|
||||
@use '../../../styles/_globals.scss' as *;
|
||||
|
||||
|
|
|
|||
|
|
@ -47,33 +47,35 @@
|
|||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
@if (providerType !== 'supersync') {
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<div class="remove-encryption-section">
|
||||
<h3>{{ textKeys.L_REMOVE_ENCRYPTION | translate }}</h3>
|
||||
<p class="remove-warning">
|
||||
{{ textKeys.REMOVE_ENCRYPTION_WARNING | translate }}
|
||||
</p>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="isLoading() || isRemovingEncryption()"
|
||||
(click)="removeEncryption()"
|
||||
>
|
||||
@if (isRemovingEncryption()) {
|
||||
<mat-spinner
|
||||
diameter="20"
|
||||
aria-label="Processing"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<mat-icon aria-hidden="true">lock_open</mat-icon>
|
||||
{{ textKeys.BTN_DISABLE_ENCRYPTION | translate }}
|
||||
</ng-container>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
<div class="remove-encryption-section">
|
||||
<h3>{{ textKeys.L_REMOVE_ENCRYPTION | translate }}</h3>
|
||||
<p class="remove-warning">
|
||||
{{ textKeys.REMOVE_ENCRYPTION_WARNING | translate }}
|
||||
</p>
|
||||
<button
|
||||
mat-stroked-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="isLoading() || isRemovingEncryption()"
|
||||
(click)="removeEncryption()"
|
||||
>
|
||||
@if (isRemovingEncryption()) {
|
||||
<mat-spinner
|
||||
diameter="20"
|
||||
aria-label="Processing"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<mat-icon aria-hidden="true">lock_open</mat-icon>
|
||||
{{ textKeys.BTN_DISABLE_ENCRYPTION | translate }}
|
||||
</ng-container>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="warning-box">
|
||||
<mat-icon aria-hidden="true">warning</mat-icon>
|
||||
|
|
|
|||
|
|
@ -14,11 +14,12 @@ 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 { EncryptionDisableService } from '../encryption-disable.service';
|
||||
import { SuperSyncEncryptionToggleService } from '../supersync-encryption-toggle.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { FileBasedEncryptionService } from '../file-based-encryption.service';
|
||||
import { SyncWrapperService } from '../sync-wrapper.service';
|
||||
|
||||
export interface ChangeEncryptionPasswordResult {
|
||||
success: boolean;
|
||||
|
|
@ -29,8 +30,8 @@ export interface ChangeEncryptionPasswordDialogData {
|
|||
mode?: 'full' | 'disable-only';
|
||||
/**
|
||||
* Type of sync provider. Determines which disable method to call.
|
||||
* - 'supersync': Uses disableEncryption() (deletes server data + uploads)
|
||||
* - 'file-based': Uses disableEncryptionForFileBased() (just uploads unencrypted)
|
||||
* - 'supersync': Uses SuperSyncEncryptionToggleService.disableEncryption() (deletes server data + uploads)
|
||||
* - 'file-based': Uses FileBasedEncryptionService.disableEncryption() (just uploads unencrypted)
|
||||
*/
|
||||
providerType?: 'supersync' | 'file-based';
|
||||
}
|
||||
|
|
@ -59,7 +60,8 @@ export interface ChangeEncryptionPasswordDialogData {
|
|||
export class DialogChangeEncryptionPasswordComponent {
|
||||
private _encryptionPasswordChangeService = inject(EncryptionPasswordChangeService);
|
||||
private _fileBasedEncryptionService = inject(FileBasedEncryptionService);
|
||||
private _encryptionDisableService = inject(EncryptionDisableService);
|
||||
private _encryptionToggleService = inject(SuperSyncEncryptionToggleService);
|
||||
private _syncWrapperService = inject(SyncWrapperService);
|
||||
private _snackService = inject(SnackService);
|
||||
private _matDialogRef =
|
||||
inject<
|
||||
|
|
@ -101,13 +103,17 @@ export class DialogChangeEncryptionPasswordComponent {
|
|||
|
||||
try {
|
||||
if (this.providerType === 'file-based') {
|
||||
await this._fileBasedEncryptionService.changePassword(this.newPassword);
|
||||
await this._syncWrapperService.runWithSyncBlocked(() =>
|
||||
this._fileBasedEncryptionService.changePassword(this.newPassword),
|
||||
);
|
||||
} else {
|
||||
// Always allow unsynced ops since password change does a clean slate + overwrite anyway
|
||||
await this._encryptionPasswordChangeService.changePassword(this.newPassword, {
|
||||
allowUnsyncedOps: true,
|
||||
});
|
||||
}
|
||||
this.newPassword = '';
|
||||
this.confirmPassword = '';
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: this.textKeys.CHANGE_PASSWORD_SUCCESS,
|
||||
|
|
@ -117,7 +123,8 @@ export class DialogChangeEncryptionPasswordComponent {
|
|||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: `Failed to change password: ${message}`,
|
||||
msg: T.F.SYNC.S.CHANGE_PASSWORD_FAILED,
|
||||
translateParams: { message },
|
||||
});
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
|
|
@ -137,9 +144,13 @@ export class DialogChangeEncryptionPasswordComponent {
|
|||
try {
|
||||
// Call appropriate disable method based on provider type
|
||||
if (this.providerType === 'file-based') {
|
||||
await this._encryptionDisableService.disableEncryptionForFileBased();
|
||||
await this._syncWrapperService.runWithSyncBlocked(() =>
|
||||
this._fileBasedEncryptionService.disableEncryption(),
|
||||
);
|
||||
} else {
|
||||
await this._encryptionDisableService.disableEncryption();
|
||||
await this._syncWrapperService.runWithSyncBlocked(() =>
|
||||
this._encryptionToggleService.disableEncryption(),
|
||||
);
|
||||
}
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
|
|
@ -150,7 +161,8 @@ export class DialogChangeEncryptionPasswordComponent {
|
|||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: `Failed to disable encryption: ${message}`,
|
||||
msg: T.F.SYNC.S.DISABLE_ENCRYPTION_FAILED,
|
||||
translateParams: { message },
|
||||
});
|
||||
this.isRemovingEncryption.set(false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
<h1 mat-dialog-title>
|
||||
{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_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>
|
||||
<p>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ENABLE_FIRST | 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.DISABLE_ENCRYPTION_WARNING | translate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h3>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_WHAT_HAPPENS | translate }}</h3>
|
||||
<ul>
|
||||
<li>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ITEM_1 | translate }}</li>
|
||||
<li>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ITEM_2 | translate }}</li>
|
||||
<li>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_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.DISABLE_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_open</mat-icon>
|
||||
{{ T.F.SYNC.FORM.SUPER_SYNC.BTN_DISABLE_ENCRYPTION | translate }}
|
||||
</ng-container>
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
@use '../dialog-encryption-common' as *;
|
||||
|
||||
@include dialog-encryption-common-classes;
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
} 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 { EncryptionDisableService } from '../encryption-disable.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 DisableEncryptionResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-disable-encryption',
|
||||
templateUrl: './dialog-disable-encryption.component.html',
|
||||
styleUrls: ['./dialog-disable-encryption.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatButton,
|
||||
MatIcon,
|
||||
TranslatePipe,
|
||||
MatProgressSpinner,
|
||||
],
|
||||
})
|
||||
export class DialogDisableEncryptionComponent {
|
||||
private _encryptionDisableService = inject(EncryptionDisableService);
|
||||
private _snackService = inject(SnackService);
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
private _matDialogRef =
|
||||
inject<MatDialogRef<DialogDisableEncryptionComponent, DisableEncryptionResult>>(
|
||||
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.DISABLE_ENCRYPTION_NOT_READY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider.id !== SyncProviderId.SuperSync) {
|
||||
this.canProceed.set(false);
|
||||
this.errorReason.set(T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_SUPERSYNC_ONLY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async confirm(): Promise<void> {
|
||||
if (this.isLoading() || !this.canProceed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading.set(true);
|
||||
|
||||
try {
|
||||
await this._encryptionDisableService.disableEncryption();
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: T.F.SYNC.FORM.SUPER_SYNC.DISABLE_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 disable encryption: ${message}`,
|
||||
});
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this._matDialogRef.close({ success: false });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
<h1 mat-dialog-title>
|
||||
{{ textKeys.ENABLE_ENCRYPTION_TITLE | translate }}
|
||||
@if (initialSetup) {
|
||||
{{ textKeys.SETUP_ENCRYPTION_TITLE | translate }}
|
||||
} @else {
|
||||
{{ textKeys.ENABLE_ENCRYPTION_TITLE | translate }}
|
||||
}
|
||||
</h1>
|
||||
|
||||
@if (!canProceed()) {
|
||||
|
|
@ -21,7 +25,91 @@
|
|||
{{ T.G.CLOSE | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
} @else if (initialSetup) {
|
||||
<!-- Simplified dialog for initial SuperSync setup -->
|
||||
<mat-dialog-content>
|
||||
<div class="info-box">
|
||||
<mat-icon aria-hidden="true">enhanced_encryption</mat-icon>
|
||||
<div>
|
||||
<p [innerHTML]="textKeys.ENCRYPTION_SETUP_INTRO | translate"></p>
|
||||
<p>{{ textKeys.SETUP_ENCRYPTION_PASSWORD_WARNING | translate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="password-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ T.F.SYNC.FORM.L_ENCRYPTION_PASSWORD | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[type]="showPassword() ? 'text' : 'password'"
|
||||
[(ngModel)]="password"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
[attr.aria-label]="T.G.TOGGLE_PASSWORD_VISIBILITY | translate"
|
||||
(click)="showPassword.set(!showPassword())"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
showPassword() ? 'visibility_off' : 'visibility'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
|
||||
<password-strength [password]="password" />
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ textKeys.L_CONFIRM_PASSWORD | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
[type]="showPassword() ? 'text' : 'password'"
|
||||
[(ngModel)]="confirmPassword"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
||||
@if (passwordError) {
|
||||
<div class="password-error">
|
||||
<mat-icon aria-hidden="true">error</mat-icon>
|
||||
<span>{{ passwordError | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
[disabled]="isLoading()"
|
||||
(click)="disableSuperSync()"
|
||||
>
|
||||
{{ textKeys.SETUP_DISABLE_SUPERSYNC_BTN | translate }}
|
||||
</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
type="button"
|
||||
[disabled]="isLoading() || !isPasswordValid"
|
||||
(click)="confirm()"
|
||||
>
|
||||
@if (isLoading()) {
|
||||
<mat-spinner
|
||||
diameter="20"
|
||||
[attr.aria-label]="T.G.PROCESSING | translate"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<mat-icon aria-hidden="true">lock</mat-icon>
|
||||
{{ textKeys.SETUP_ENCRYPTION_BTN | translate }}
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
} @else {
|
||||
<!-- Full dialog for enabling encryption on existing setup -->
|
||||
<mat-dialog-content>
|
||||
<div class="warning-box">
|
||||
<mat-icon aria-hidden="true">warning</mat-icon>
|
||||
|
|
@ -33,18 +121,33 @@
|
|||
<mat-label>{{ T.F.SYNC.FORM.L_ENCRYPTION_PASSWORD | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="password"
|
||||
[type]="showPassword() ? 'text' : 'password'"
|
||||
[(ngModel)]="password"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
type="button"
|
||||
[attr.aria-label]="T.G.TOGGLE_PASSWORD_VISIBILITY | translate"
|
||||
(click)="showPassword.set(!showPassword())"
|
||||
>
|
||||
<mat-icon aria-hidden="true">{{
|
||||
showPassword() ? 'visibility_off' : 'visibility'
|
||||
}}</mat-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
|
||||
<password-strength [password]="password" />
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>{{ textKeys.L_CONFIRM_PASSWORD | translate }}</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="password"
|
||||
[type]="showPassword() ? 'text' : 'password'"
|
||||
[(ngModel)]="confirmPassword"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</mat-form-field>
|
||||
|
|
@ -91,13 +194,11 @@
|
|||
@if (isLoading()) {
|
||||
<mat-spinner
|
||||
diameter="20"
|
||||
aria-label="Processing"
|
||||
[attr.aria-label]="T.G.PROCESSING | translate"
|
||||
></mat-spinner>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<mat-icon aria-hidden="true">lock</mat-icon>
|
||||
{{ textKeys.BTN_ENABLE_ENCRYPTION | translate }}
|
||||
</ng-container>
|
||||
<mat-icon aria-hidden="true">lock</mat-icon>
|
||||
{{ textKeys.BTN_ENABLE_ENCRYPTION | translate }}
|
||||
}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,25 @@
|
|||
@use '../dialog-encryption-common' as *;
|
||||
|
||||
@include dialog-encryption-common-classes;
|
||||
|
||||
.info-box {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.password-fields {
|
||||
margin-bottom: 16px;
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.password-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--c-warn);
|
||||
font-size: 13px;
|
||||
margin-top: -4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,20 +10,25 @@ 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 { SuperSyncEncryptionToggleService } from '../supersync-encryption-toggle.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 { SyncWrapperService } from '../sync-wrapper.service';
|
||||
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
|
||||
import { isFileBasedProvider } from '../../../op-log/sync/operation-sync.util';
|
||||
import { FileBasedEncryptionService } from '../file-based-encryption.service';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatFormField, MatLabel } from '@angular/material/form-field';
|
||||
import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { GlobalConfigService } from '../../../features/config/global-config.service';
|
||||
import { PasswordStrengthComponent } from '../../../ui/password-strength/password-strength.component';
|
||||
|
||||
export interface EnableEncryptionDialogData {
|
||||
encryptKey?: string;
|
||||
providerType?: 'supersync' | 'file-based';
|
||||
initialSetup?: boolean;
|
||||
}
|
||||
|
||||
export interface EnableEncryptionResult {
|
||||
|
|
@ -47,13 +52,18 @@ export interface EnableEncryptionResult {
|
|||
MatFormField,
|
||||
MatLabel,
|
||||
MatInput,
|
||||
MatSuffix,
|
||||
MatIconButton,
|
||||
PasswordStrengthComponent,
|
||||
],
|
||||
})
|
||||
export class DialogEnableEncryptionComponent {
|
||||
private _encryptionEnableService = inject(EncryptionEnableService);
|
||||
private _encryptionToggleService = inject(SuperSyncEncryptionToggleService);
|
||||
private _fileBasedEncryptionService = inject(FileBasedEncryptionService);
|
||||
private _snackService = inject(SnackService);
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
private _syncWrapperService = inject(SyncWrapperService);
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private _data = inject<EnableEncryptionDialogData | null>(MAT_DIALOG_DATA, {
|
||||
optional: true,
|
||||
});
|
||||
|
|
@ -66,6 +76,8 @@ export class DialogEnableEncryptionComponent {
|
|||
isLoading = signal(false);
|
||||
canProceed = signal(true);
|
||||
errorReason = signal<string | null>(null);
|
||||
showPassword = signal(false);
|
||||
initialSetup: boolean = this._data?.initialSetup || false;
|
||||
providerType: 'supersync' | 'file-based' = this._data?.providerType || 'supersync';
|
||||
textKeys: Record<string, string> =
|
||||
this.providerType === 'file-based'
|
||||
|
|
@ -80,7 +92,9 @@ export class DialogEnableEncryptionComponent {
|
|||
readonly MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
constructor() {
|
||||
this._checkPreconditions();
|
||||
if (!this.initialSetup) {
|
||||
this._checkPreconditions();
|
||||
}
|
||||
}
|
||||
|
||||
get isPasswordValid(): boolean {
|
||||
|
|
@ -131,10 +145,16 @@ export class DialogEnableEncryptionComponent {
|
|||
|
||||
try {
|
||||
if (this.providerType === 'file-based') {
|
||||
await this._fileBasedEncryptionService.enableEncryption(this.password);
|
||||
await this._syncWrapperService.runWithSyncBlocked(() =>
|
||||
this._fileBasedEncryptionService.enableEncryption(this.password),
|
||||
);
|
||||
} else {
|
||||
await this._encryptionEnableService.enableEncryption(this.password);
|
||||
await this._syncWrapperService.runWithSyncBlocked(() =>
|
||||
this._encryptionToggleService.enableEncryption(this.password),
|
||||
);
|
||||
}
|
||||
this.password = '';
|
||||
this.confirmPassword = '';
|
||||
this._snackService.open({
|
||||
type: 'SUCCESS',
|
||||
msg: this.textKeys.ENABLE_ENCRYPTION_SUCCESS,
|
||||
|
|
@ -144,12 +164,18 @@ export class DialogEnableEncryptionComponent {
|
|||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: `Failed to enable encryption: ${message}`,
|
||||
msg: T.F.SYNC.S.ENABLE_ENCRYPTION_FAILED,
|
||||
translateParams: { message },
|
||||
});
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
disableSuperSync(): void {
|
||||
this._globalConfigService.updateSection('sync', { isEnabled: false });
|
||||
this._matDialogRef.close({ success: false });
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this._matDialogRef.close({ success: false });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@
|
|||
/>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
|
||||
@if (isSuperSync()) {
|
||||
<p class="force-overwrite-info">
|
||||
{{ T.F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_INFO | translate }}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
|
|
@ -32,10 +38,22 @@
|
|||
[disabled]="isLoading()"
|
||||
(click)="cancel()"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
|
||||
@if (isSuperSync()) {
|
||||
<button
|
||||
mat-flat-button
|
||||
color="warn"
|
||||
type="button"
|
||||
[disabled]="!passwordVal || isLoading()"
|
||||
(click)="forceOverwrite()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">cloud_upload</mat-icon>
|
||||
{{ T.F.SYNC.D_ENTER_PASSWORD.BTN_FORCE_OVERWRITE | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
mat-flat-button
|
||||
color="primary"
|
||||
|
|
@ -43,7 +61,7 @@
|
|||
[disabled]="!passwordVal || isLoading()"
|
||||
(click)="saveAndSync()"
|
||||
>
|
||||
<mat-icon>sync</mat-icon>
|
||||
<mat-icon aria-hidden="true">sync</mat-icon>
|
||||
{{ T.F.SYNC.D_ENTER_PASSWORD.BTN_SAVE_AND_SYNC | translate }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.force-overwrite-note {
|
||||
.force-overwrite-info {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { FormsModule } from '@angular/forms';
|
|||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { SyncConfigService } from '../sync-config.service';
|
||||
import { EncryptionPasswordChangeService } from '../encryption-password-change.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
|
|
@ -73,7 +74,9 @@ export class DialogEnterEncryptionPasswordComponent {
|
|||
this.isLoading.set(true);
|
||||
try {
|
||||
await this._syncConfigService.updateEncryptionPassword(this.passwordVal);
|
||||
this._matDialogRef.close({ password: this.passwordVal });
|
||||
const pw = this.passwordVal;
|
||||
this.passwordVal = '';
|
||||
this._matDialogRef.close({ password: pw });
|
||||
} catch (error) {
|
||||
SyncLog.err('Failed to save encryption password', error);
|
||||
this._snackService.open({
|
||||
|
|
@ -90,16 +93,17 @@ export class DialogEnterEncryptionPasswordComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
const confirmed = await this._matDialog
|
||||
.open(DialogConfirmComponent, {
|
||||
data: {
|
||||
title: T.F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_TITLE,
|
||||
message: T.F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_CONFIRM,
|
||||
okTxt: T.F.SYNC.D_ENTER_PASSWORD.BTN_FORCE_OVERWRITE,
|
||||
},
|
||||
})
|
||||
.afterClosed()
|
||||
.toPromise();
|
||||
const confirmed = await firstValueFrom(
|
||||
this._matDialog
|
||||
.open(DialogConfirmComponent, {
|
||||
data: {
|
||||
title: T.F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_TITLE,
|
||||
message: T.F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_CONFIRM,
|
||||
okTxt: T.F.SYNC.D_ENTER_PASSWORD.BTN_FORCE_OVERWRITE,
|
||||
},
|
||||
})
|
||||
.afterClosed(),
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
|
|
@ -115,7 +119,8 @@ export class DialogEnterEncryptionPasswordComponent {
|
|||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: `Failed to overwrite server data: ${message}`,
|
||||
msg: T.F.SYNC.S.OVERWRITE_SERVER_FAILED,
|
||||
translateParams: { message },
|
||||
});
|
||||
} finally {
|
||||
this.isLoading.set(false);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import { MatButton } from '@angular/material/button';
|
|||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { SyncConfigService } from '../sync-config.service';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { SyncLog } from '../../../core/log';
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-handle-decrypt-error',
|
||||
|
|
@ -34,6 +36,7 @@ import { SyncConfigService } from '../sync-config.service';
|
|||
})
|
||||
export class DialogHandleDecryptErrorComponent {
|
||||
private _syncConfigService = inject(SyncConfigService);
|
||||
private _snackService = inject(SnackService);
|
||||
|
||||
private _matDialogRef =
|
||||
inject<MatDialogRef<DialogHandleDecryptErrorComponent>>(MatDialogRef);
|
||||
|
|
@ -42,16 +45,35 @@ export class DialogHandleDecryptErrorComponent {
|
|||
passwordVal: string = '';
|
||||
|
||||
async updatePWAndForceUpload(): Promise<void> {
|
||||
await this._syncConfigService.updateEncryptionPassword(this.passwordVal);
|
||||
this._matDialogRef.close({ isForceUpload: true });
|
||||
try {
|
||||
await this._syncConfigService.updateEncryptionPassword(this.passwordVal);
|
||||
this.passwordVal = '';
|
||||
this._matDialogRef.close({ isForceUpload: true });
|
||||
} catch (error) {
|
||||
SyncLog.err('Failed to save encryption password for force upload', error);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.SYNC.S.PERSIST_FAILED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updatePwAndResync(): Promise<void> {
|
||||
await this._syncConfigService.updateEncryptionPassword(this.passwordVal);
|
||||
this._matDialogRef.close({ isReSync: true });
|
||||
try {
|
||||
await this._syncConfigService.updateEncryptionPassword(this.passwordVal);
|
||||
this.passwordVal = '';
|
||||
this._matDialogRef.close({ isReSync: true });
|
||||
} catch (error) {
|
||||
SyncLog.err('Failed to save encryption password for resync', error);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.SYNC.S.PERSIST_FAILED,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.passwordVal = '';
|
||||
this._matDialogRef.close({});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
<mat-dialog-content>
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<mat-icon
|
||||
color="warn"
|
||||
style="font-size: 48px; height: 48px; width: 48px"
|
||||
>error
|
||||
</mat-icon>
|
||||
</div>
|
||||
|
||||
<p><strong>The date of your remote data are from the future</strong></p>
|
||||
<p>You should check the time configured on your systems!</p>
|
||||
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<button
|
||||
(click)="downloadBackup()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<div class="wrap-buttons">
|
||||
<button
|
||||
(click)="close()"
|
||||
color="primary"
|
||||
mat-button
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="close('FORCE_UPDATE_LOCAL')"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
Force Download Remote
|
||||
<!-- {{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}-->
|
||||
</button>
|
||||
|
||||
<button
|
||||
(click)="close('FORCE_UPDATE_REMOTE')"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon>file_upload</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { download } from '../../../util/download';
|
||||
|
||||
import { IS_ELECTRON } from '../../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { BackupService } from '../../../op-log/backup/backup.service';
|
||||
import { T } from '../../../t.const';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
export interface DialogIncompleteSyncData {
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-incoherent-timestamps-error',
|
||||
imports: [
|
||||
MatDialogContent,
|
||||
TranslateModule,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogActions,
|
||||
MatButton,
|
||||
],
|
||||
templateUrl: './dialog-incoherent-timestamps-error.component.html',
|
||||
styleUrl: './dialog-incoherent-timestamps-error.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DialogIncoherentTimestampsErrorComponent {
|
||||
private _matDialogRef =
|
||||
inject<MatDialogRef<DialogIncoherentTimestampsErrorComponent>>(MatDialogRef);
|
||||
private _backupService = inject(BackupService);
|
||||
|
||||
data = inject<DialogIncompleteSyncData>(MAT_DIALOG_DATA);
|
||||
|
||||
T: typeof T = T;
|
||||
IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW;
|
||||
|
||||
constructor() {
|
||||
const _matDialogRef = this._matDialogRef;
|
||||
_matDialogRef.disableClose = true;
|
||||
}
|
||||
|
||||
async downloadBackup(): Promise<void> {
|
||||
const data = await this._backupService.loadCompleteBackup(true);
|
||||
try {
|
||||
await download('super-productivity-backup.json', JSON.stringify(data));
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
}
|
||||
// download('super-productivity-backup.json', privacyExport(data));
|
||||
}
|
||||
|
||||
close(res?: 'FORCE_UPDATE_REMOTE' | 'FORCE_UPDATE_LOCAL'): void {
|
||||
this._matDialogRef.close(res);
|
||||
}
|
||||
|
||||
closeApp(): void {
|
||||
if (IS_ELECTRON) {
|
||||
window.ea.shutdownNow();
|
||||
} else {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
<mat-dialog-content>
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<mat-icon
|
||||
color="warn"
|
||||
style="font-size: 48px; height: 48px; width: 48px"
|
||||
>error
|
||||
</mat-icon>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.T1 | translate }} </strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.T2 | translate }}
|
||||
<code
|
||||
>mfRev: {{ data?.archiveRevInMainFile }} & realRev: {{ data?.archiveRevReal }}</code
|
||||
>
|
||||
</p>
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}</p>
|
||||
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}</p>
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}</p>
|
||||
|
||||
<p style="text-align: center; margin-top: 16px">
|
||||
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} </strong>
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<button
|
||||
(click)="downloadBackup()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<div class="wrap-buttons">
|
||||
<button
|
||||
(click)="close()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
@if (!IS_ANDROID_WEB_VIEW) {
|
||||
<button
|
||||
(click)="closeApp()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon>power_settings_new</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_CLOSE_APP | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
(click)="close('FORCE_UPDATE_REMOTE')"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon>file_upload</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
:host > * {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
:host code {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogActions,
|
||||
MatDialogContent,
|
||||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { download } from '../../../util/download';
|
||||
|
||||
import { IS_ELECTRON } from '../../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { BackupService } from '../../../op-log/backup/backup.service';
|
||||
import { T } from '../../../t.const';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
export interface DialogIncompleteSyncData {
|
||||
archiveRevInMainFile?: string;
|
||||
archiveRevReal?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-incomplete-sync-old',
|
||||
imports: [
|
||||
MatDialogContent,
|
||||
TranslateModule,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogActions,
|
||||
MatButton,
|
||||
],
|
||||
templateUrl: './dialog-incomplete-sync-old.component.html',
|
||||
styleUrl: './dialog-incomplete-sync-old.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DialogIncompleteSyncOldComponent {
|
||||
private _matDialogRef =
|
||||
inject<MatDialogRef<DialogIncompleteSyncOldComponent>>(MatDialogRef);
|
||||
private _backupService = inject(BackupService);
|
||||
data? = inject<DialogIncompleteSyncData>(MAT_DIALOG_DATA);
|
||||
|
||||
T: typeof T = T;
|
||||
IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW;
|
||||
|
||||
constructor() {
|
||||
const _matDialogRef = this._matDialogRef;
|
||||
|
||||
_matDialogRef.disableClose = true;
|
||||
}
|
||||
|
||||
async downloadBackup(): Promise<void> {
|
||||
const data = await this._backupService.loadCompleteBackup(true);
|
||||
try {
|
||||
await download('super-productivity-backup.json', JSON.stringify(data));
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
}
|
||||
// download('super-productivity-backup.json', privacyExport(data));
|
||||
}
|
||||
|
||||
close(res?: 'FORCE_UPDATE_REMOTE'): void {
|
||||
this._matDialogRef.close(res);
|
||||
}
|
||||
|
||||
closeApp(): void {
|
||||
if (IS_ELECTRON) {
|
||||
window.ea.shutdownNow();
|
||||
} else {
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<mat-dialog-content>
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<mat-icon
|
||||
color="warn"
|
||||
style="font-size: 48px; height: 48px; width: 48px"
|
||||
>error
|
||||
</mat-icon>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.P1 | translate }} </strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.P2 | translate }}
|
||||
<strong>{{ data?.modelId }}</strong>
|
||||
</p>
|
||||
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}</p>
|
||||
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}</p>
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}</p>
|
||||
|
||||
<p style="text-align: center; margin-top: 16px">
|
||||
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} </strong>
|
||||
</p>
|
||||
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<button
|
||||
(click)="downloadBackup()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
>
|
||||
<mat-icon>file_download</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<div class="wrap-buttons">
|
||||
<button
|
||||
(click)="close()"
|
||||
color="primary"
|
||||
mat-button
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
@if (!IS_ANDROID_WEB_VIEW) {
|
||||
<button
|
||||
(click)="closeApp()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon>power_settings_new</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_CLOSE_APP | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
(click)="close('FORCE_UPDATE_REMOTE')"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon>file_upload</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
:host > * {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
:host code {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<mat-dialog-content>
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<mat-icon
|
||||
color="warn"
|
||||
style="font-size: 48px; height: 48px; width: 48px"
|
||||
aria-hidden="true"
|
||||
>error
|
||||
</mat-icon>
|
||||
</div>
|
||||
|
||||
@if (data.type === 'incomplete-sync') {
|
||||
<p>
|
||||
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.P1 | translate }} </strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.P2 | translate }}
|
||||
<strong>{{ data.modelId }}</strong>
|
||||
</p>
|
||||
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}</p>
|
||||
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}</p>
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}</p>
|
||||
|
||||
<p style="text-align: center; margin-top: 16px">
|
||||
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} </strong>
|
||||
</p>
|
||||
} @else {
|
||||
<p>
|
||||
<strong>{{ T.F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P1 | translate }}</strong>
|
||||
</p>
|
||||
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P2 | translate }}</p>
|
||||
}
|
||||
|
||||
<div style="text-align: center; margin-bottom: 16px">
|
||||
<button
|
||||
(click)="downloadBackup()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
>
|
||||
<mat-icon aria-hidden="true">file_download</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<div class="wrap-buttons">
|
||||
<button
|
||||
(click)="close()"
|
||||
color="primary"
|
||||
mat-button
|
||||
>
|
||||
{{ T.G.CANCEL | translate }}
|
||||
</button>
|
||||
|
||||
@if (data.type === 'incomplete-sync' && !IS_ANDROID_WEB_VIEW) {
|
||||
<button
|
||||
(click)="closeApp()"
|
||||
color="primary"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon aria-hidden="true">power_settings_new</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_CLOSE_APP | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (data.type === 'incoherent-timestamps') {
|
||||
<button
|
||||
(click)="close('FORCE_UPDATE_LOCAL')"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon aria-hidden="true">file_download</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_DOWNLOAD | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
(click)="close('FORCE_UPDATE_REMOTE')"
|
||||
color="warn"
|
||||
mat-stroked-button
|
||||
>
|
||||
<mat-icon aria-hidden="true">file_upload</mat-icon>
|
||||
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-dialog-actions>
|
||||
|
|
@ -6,7 +6,6 @@ import {
|
|||
MatDialogRef,
|
||||
} from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { download } from '../../../util/download';
|
||||
|
||||
import { IS_ELECTRON } from '../../../app.constants';
|
||||
|
|
@ -17,37 +16,36 @@ import { BackupService } from '../../../op-log/backup/backup.service';
|
|||
import { T } from '../../../t.const';
|
||||
import { Log } from '../../../core/log';
|
||||
|
||||
export interface DialogIncompleteSyncData {
|
||||
modelId: string;
|
||||
export type DialogSyncErrorType = 'incomplete-sync' | 'incoherent-timestamps';
|
||||
|
||||
export interface DialogSyncErrorData {
|
||||
type: DialogSyncErrorType;
|
||||
modelId?: string;
|
||||
}
|
||||
|
||||
export type DialogSyncErrorResult =
|
||||
| 'FORCE_UPDATE_REMOTE'
|
||||
| 'FORCE_UPDATE_LOCAL'
|
||||
| undefined;
|
||||
|
||||
@Component({
|
||||
selector: 'dialog-incomplete-sync',
|
||||
imports: [
|
||||
MatDialogContent,
|
||||
TranslateModule,
|
||||
FormsModule,
|
||||
MatIcon,
|
||||
MatDialogActions,
|
||||
MatButton,
|
||||
],
|
||||
templateUrl: './dialog-incomplete-sync.component.html',
|
||||
styleUrl: './dialog-incomplete-sync.component.scss',
|
||||
selector: 'dialog-sync-error',
|
||||
imports: [MatDialogContent, TranslateModule, MatIcon, MatDialogActions, MatButton],
|
||||
templateUrl: './dialog-sync-error.component.html',
|
||||
styleUrl: './dialog-sync-error.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DialogIncompleteSyncComponent {
|
||||
private _matDialogRef =
|
||||
inject<MatDialogRef<DialogIncompleteSyncComponent>>(MatDialogRef);
|
||||
export class DialogSyncErrorComponent {
|
||||
private _matDialogRef = inject<MatDialogRef<DialogSyncErrorComponent>>(MatDialogRef);
|
||||
private _backupService = inject(BackupService);
|
||||
|
||||
data = inject<DialogIncompleteSyncData>(MAT_DIALOG_DATA);
|
||||
data = inject<DialogSyncErrorData>(MAT_DIALOG_DATA);
|
||||
|
||||
T: typeof T = T;
|
||||
IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW;
|
||||
|
||||
constructor() {
|
||||
const _matDialogRef = this._matDialogRef;
|
||||
_matDialogRef.disableClose = true;
|
||||
this._matDialogRef.disableClose = true;
|
||||
}
|
||||
|
||||
async downloadBackup(): Promise<void> {
|
||||
|
|
@ -57,10 +55,9 @@ export class DialogIncompleteSyncComponent {
|
|||
} catch (e) {
|
||||
Log.error(e);
|
||||
}
|
||||
// download('super-productivity-backup.json', privacyExport(data));
|
||||
}
|
||||
|
||||
close(res?: 'FORCE_UPDATE_REMOTE'): void {
|
||||
close(res?: DialogSyncErrorResult): void {
|
||||
this._matDialogRef.close(res);
|
||||
}
|
||||
|
||||
|
|
@ -29,6 +29,7 @@ import { first, skip } from 'rxjs/operators';
|
|||
import { toSyncProviderId } from '../../../op-log/sync-exports';
|
||||
import { SyncLog } from '../../../core/log';
|
||||
import { SyncProviderManager } from '../../../op-log/sync-providers/provider-manager.service';
|
||||
|
||||
import { GlobalConfigService } from '../../../features/config/global-config.service';
|
||||
import { isOnline } from '../../../util/is-online';
|
||||
|
||||
|
|
@ -62,15 +63,18 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit {
|
|||
private _getFields(includeEnabledToggle: boolean): FormlyFieldConfig[] {
|
||||
return SYNC_FORM.items!.filter((f) => includeEnabledToggle || f.key !== 'isEnabled');
|
||||
}
|
||||
_tmpUpdatedCfg: SyncConfig = {
|
||||
// Note: _isInitialSetup flag is checked by sync-form.const.ts hideExpressions
|
||||
// to hide the encryption button/warning (encryption is handled by _promptSuperSyncEncryptionIfNeeded after sync)
|
||||
_tmpUpdatedCfg: SyncConfig & { _isInitialSetup?: boolean } = {
|
||||
isEnabled: true,
|
||||
syncProvider: null,
|
||||
syncProvider: SyncProviderId.SuperSync,
|
||||
syncInterval: 300000,
|
||||
encryptKey: '',
|
||||
isEncryptionEnabled: false,
|
||||
localFileSync: {},
|
||||
webDav: {},
|
||||
superSync: {},
|
||||
_isInitialSetup: true,
|
||||
};
|
||||
|
||||
private _matDialogRef =
|
||||
|
|
@ -203,12 +207,15 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit {
|
|||
...this.form.value,
|
||||
};
|
||||
|
||||
// Strip _isInitialSetup before saving — it's only for form hideExpressions
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { _isInitialSetup, ...cfgWithoutFlag } = this._tmpUpdatedCfg;
|
||||
const configToSave = {
|
||||
...this._tmpUpdatedCfg,
|
||||
...cfgWithoutFlag,
|
||||
isEnabled: this._tmpUpdatedCfg.isEnabled || !this.isWasEnabled(),
|
||||
};
|
||||
|
||||
await this.syncConfigService.updateSettingsFromForm(configToSave, true);
|
||||
await this.syncConfigService.updateSettingsFromForm(configToSave as SyncConfig, true);
|
||||
const providerId = toSyncProviderId(this._tmpUpdatedCfg.syncProvider);
|
||||
if (providerId && this._tmpUpdatedCfg.isEnabled) {
|
||||
await this.syncWrapperService.configuredAuthForSyncProviderIfNecessary(providerId);
|
||||
|
|
|
|||
|
|
@ -1,231 +0,0 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { SnapshotUploadService } from './snapshot-upload.service';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
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 { CURRENT_SCHEMA_VERSION } from '../../op-log/persistence/schema-migration.service';
|
||||
import { uuidv7 } from '../../util/uuid-v7';
|
||||
import { isFileBasedProvider } from '../../op-log/sync/operation-sync.util';
|
||||
import { FileBasedSyncAdapterService } from '../../op-log/sync-providers/file-based/file-based-sync-adapter.service';
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
|
||||
const LOG_PREFIX = 'EncryptionDisableService';
|
||||
|
||||
/**
|
||||
* Service for disabling encryption for sync providers.
|
||||
*
|
||||
* ## SuperSync
|
||||
* Disable encryption flow:
|
||||
* 1. Delete all data on server (encrypted operations can't be mixed with unencrypted)
|
||||
* 2. Upload current state as unencrypted snapshot
|
||||
* 3. Update local config to disable encryption and clear the key
|
||||
*
|
||||
* ## File-based providers (Dropbox, WebDAV, LocalFile)
|
||||
* Disable encryption flow:
|
||||
* 1. Get current local state snapshot
|
||||
* 2. Upload unencrypted snapshot (replace encrypted sync file)
|
||||
* 3. Update local config to disable encryption and clear the key
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EncryptionDisableService {
|
||||
private _snapshotUploadService = inject(SnapshotUploadService);
|
||||
private _wrappedProviderService = inject(WrappedProviderService);
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
private _stateSnapshotService = inject(StateSnapshotService);
|
||||
private _vectorClockService = inject(VectorClockService);
|
||||
private _clientIdProvider: ClientIdProvider = inject(CLIENT_ID_PROVIDER);
|
||||
private _fileBasedAdapter = inject(FileBasedSyncAdapterService);
|
||||
private _globalConfigService = inject(GlobalConfigService);
|
||||
|
||||
/**
|
||||
* Disables encryption by deleting all server data
|
||||
* and uploading a new unencrypted snapshot.
|
||||
*
|
||||
* @throws Error if sync provider is not SuperSync or not ready
|
||||
*/
|
||||
async disableEncryption(): Promise<void> {
|
||||
SyncLog.normal(`${LOG_PREFIX}: Starting encryption disable...`);
|
||||
|
||||
// Gather all data needed for upload (validates provider)
|
||||
const { syncProvider, existingCfg, state, vectorClock, clientId } =
|
||||
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
|
||||
|
||||
// Delete all server data (encrypted ops can't be mixed with unencrypted)
|
||||
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
|
||||
await syncProvider.deleteAllData();
|
||||
|
||||
// Upload unencrypted snapshot
|
||||
SyncLog.normal(`${LOG_PREFIX}: Uploading unencrypted snapshot...`);
|
||||
try {
|
||||
const result = await this._snapshotUploadService.uploadSnapshot(
|
||||
syncProvider,
|
||||
state,
|
||||
clientId,
|
||||
vectorClock,
|
||||
false, // isPayloadEncrypted = false
|
||||
);
|
||||
|
||||
if (!result.accepted) {
|
||||
throw new Error(`Snapshot upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
// Update local config AFTER successful upload - disable encryption and clear the key
|
||||
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
|
||||
await syncProvider.setPrivateCfg({
|
||||
...existingCfg,
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
} as SuperSyncPrivateCfg);
|
||||
|
||||
// Clear cached adapters to ensure new encryption settings take effect
|
||||
this._wrappedProviderService.clearCache();
|
||||
|
||||
// Update lastServerSeq
|
||||
await this._snapshotUploadService.updateLastServerSeq(
|
||||
syncProvider,
|
||||
result.serverSeq,
|
||||
LOG_PREFIX,
|
||||
);
|
||||
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encryption disabled successfully!`);
|
||||
} catch (uploadError) {
|
||||
// CRITICAL: Server data was deleted but new snapshot failed to upload.
|
||||
SyncLog.err(
|
||||
`${LOG_PREFIX}: Snapshot upload failed after deleting server data!`,
|
||||
uploadError,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
'CRITICAL: Failed to upload unencrypted snapshot after deleting server data. ' +
|
||||
'Your local data is safe. Please use "Sync Now" to re-upload your data. ' +
|
||||
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables encryption for file-based sync providers (Dropbox, WebDAV, LocalFile).
|
||||
*
|
||||
* Flow:
|
||||
* 1. Validate provider is file-based and ready
|
||||
* 2. Get current local state snapshot
|
||||
* 3. Create unencrypted adapter and upload snapshot
|
||||
* 4. Update local config to disable encryption and clear the key
|
||||
* 5. Clear caches
|
||||
*
|
||||
* @throws Error if sync provider is not file-based or not ready
|
||||
*/
|
||||
async disableEncryptionForFileBased(): Promise<void> {
|
||||
SyncLog.normal(
|
||||
`${LOG_PREFIX}: Starting encryption disable for file-based provider...`,
|
||||
);
|
||||
|
||||
// Get active provider
|
||||
const provider = this._providerManager.getActiveProvider();
|
||||
if (!provider) {
|
||||
throw new Error('No active sync provider. Please enable sync first.');
|
||||
}
|
||||
|
||||
// Validate it's a file-based provider
|
||||
if (!isFileBasedProvider(provider)) {
|
||||
throw new Error(
|
||||
`This operation is only supported for file-based providers (Dropbox, WebDAV, LocalFile). ` +
|
||||
`Current provider: ${provider.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check provider is ready
|
||||
if (!(await provider.isReady())) {
|
||||
throw new Error('Sync provider is not ready. Please configure sync first.');
|
||||
}
|
||||
|
||||
// Get current state
|
||||
SyncLog.normal(`${LOG_PREFIX}: Getting current state...`);
|
||||
const state = await this._stateSnapshotService.getStateSnapshotAsync();
|
||||
const vectorClock = await this._vectorClockService.getCurrentVectorClock();
|
||||
const clientId = await this._clientIdProvider.loadClientId();
|
||||
|
||||
if (!clientId) {
|
||||
throw new Error('Client ID not available');
|
||||
}
|
||||
|
||||
// Get existing config
|
||||
const existingCfg = await provider.privateCfg.load();
|
||||
|
||||
// Create unencrypted adapter (pass undefined for encryptKey)
|
||||
SyncLog.normal(`${LOG_PREFIX}: Creating unencrypted adapter...`);
|
||||
const baseCfg = this._providerManager.getEncryptAndCompressCfg();
|
||||
const unencryptedCfg = {
|
||||
...baseCfg,
|
||||
isEncrypt: false, // Explicitly disable encryption
|
||||
};
|
||||
|
||||
const adapter = this._fileBasedAdapter.createAdapter(
|
||||
provider,
|
||||
unencryptedCfg,
|
||||
undefined, // No encryption key
|
||||
);
|
||||
|
||||
// Upload unencrypted snapshot
|
||||
SyncLog.normal(`${LOG_PREFIX}: Uploading unencrypted snapshot...`);
|
||||
try {
|
||||
const result = await adapter.uploadSnapshot(
|
||||
state,
|
||||
clientId,
|
||||
'recovery',
|
||||
vectorClock,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
false, // isPayloadEncrypted = false
|
||||
uuidv7(),
|
||||
);
|
||||
|
||||
if (!result.accepted) {
|
||||
throw new Error(`Snapshot upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
// Update local config AFTER successful upload
|
||||
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
|
||||
|
||||
// Update provider's private config (encryptKey)
|
||||
await provider.setPrivateCfg({
|
||||
...existingCfg,
|
||||
encryptKey: undefined,
|
||||
});
|
||||
|
||||
// Update global sync config (isEncryptionEnabled, encryptKey)
|
||||
this._globalConfigService.updateSection('sync', {
|
||||
isEncryptionEnabled: false,
|
||||
encryptKey: '',
|
||||
});
|
||||
|
||||
// Clear cached adapters to ensure new encryption settings take effect
|
||||
this._wrappedProviderService.clearCache();
|
||||
|
||||
// Update lastServerSeq
|
||||
if (result.serverSeq !== undefined) {
|
||||
await adapter.setLastServerSeq(result.serverSeq);
|
||||
}
|
||||
|
||||
SyncLog.normal(
|
||||
`${LOG_PREFIX}: Encryption disabled successfully for file-based provider!`,
|
||||
);
|
||||
} catch (uploadError) {
|
||||
SyncLog.err(`${LOG_PREFIX}: Failed to upload unencrypted snapshot!`, uploadError);
|
||||
|
||||
throw new Error(
|
||||
'Failed to upload unencrypted snapshot. ' +
|
||||
'Your local data is safe. Please try again. ' +
|
||||
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { SnapshotUploadService } from './snapshot-upload.service';
|
||||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption';
|
||||
import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors';
|
||||
|
||||
const LOG_PREFIX = 'EncryptionEnableService';
|
||||
|
||||
/**
|
||||
* Service for enabling encryption for SuperSync.
|
||||
*
|
||||
* Enable encryption flow:
|
||||
* 1. Delete all data on server (unencrypted operations can't be mixed with encrypted)
|
||||
* 2. Update local config BEFORE upload (so upload uses the new key)
|
||||
* 3. Upload current state as encrypted snapshot
|
||||
* 4. Revert config on failure
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EncryptionEnableService {
|
||||
private _snapshotUploadService = inject(SnapshotUploadService);
|
||||
private _encryptionService = inject(OperationEncryptionService);
|
||||
private _wrappedProviderService = inject(WrappedProviderService);
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
|
||||
/**
|
||||
* 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(`${LOG_PREFIX}: Starting encryption enable...`);
|
||||
|
||||
if (!encryptKey) {
|
||||
throw new Error('Encryption key is required');
|
||||
}
|
||||
|
||||
// CRITICAL: Check crypto availability BEFORE deleting server data
|
||||
// to prevent data loss if encryption will fail
|
||||
if (!isCryptoSubtleAvailable()) {
|
||||
throw new WebCryptoNotAvailableError(
|
||||
'Cannot enable encryption: WebCrypto API is not available. ' +
|
||||
'Encryption requires a secure context (HTTPS). ' +
|
||||
'On Android, encryption is not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
// Gather all data needed for upload (validates provider)
|
||||
const { syncProvider, existingCfg, state, vectorClock, clientId } =
|
||||
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
|
||||
|
||||
// Delete all server data (unencrypted ops can't be mixed with encrypted)
|
||||
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
|
||||
await syncProvider.deleteAllData();
|
||||
|
||||
// Update local config BEFORE upload - enable encryption and set the key
|
||||
// This must happen BEFORE upload so the upload uses the new key
|
||||
// IMPORTANT: Use providerManager.setProviderConfig() instead of direct setPrivateCfg()
|
||||
// to ensure the currentProviderPrivateCfg$ observable is updated, which is needed
|
||||
// for the form to correctly show isEncryptionEnabled state.
|
||||
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
|
||||
const newConfig = {
|
||||
...existingCfg,
|
||||
encryptKey,
|
||||
isEncryptionEnabled: true,
|
||||
} as SuperSyncPrivateCfg;
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, newConfig);
|
||||
|
||||
// Clear cached adapters to ensure new encryption settings take effect
|
||||
this._wrappedProviderService.clearCache();
|
||||
|
||||
// Encrypt the snapshot payload
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encrypting snapshot...`);
|
||||
const encryptedPayload = await this._encryptionService.encryptPayload(
|
||||
state,
|
||||
encryptKey,
|
||||
);
|
||||
|
||||
// Upload encrypted snapshot
|
||||
SyncLog.normal(`${LOG_PREFIX}: Uploading encrypted snapshot...`);
|
||||
try {
|
||||
const result = await this._snapshotUploadService.uploadSnapshot(
|
||||
syncProvider,
|
||||
encryptedPayload,
|
||||
clientId,
|
||||
vectorClock,
|
||||
true, // isPayloadEncrypted = true
|
||||
);
|
||||
|
||||
if (!result.accepted) {
|
||||
throw new Error(`Snapshot upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
// Update lastServerSeq
|
||||
await this._snapshotUploadService.updateLastServerSeq(
|
||||
syncProvider,
|
||||
result.serverSeq,
|
||||
LOG_PREFIX,
|
||||
);
|
||||
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encryption enabled successfully!`);
|
||||
} catch (uploadError) {
|
||||
// CRITICAL: Server data was deleted but new snapshot failed to upload.
|
||||
// Revert local config to unencrypted state
|
||||
SyncLog.err(
|
||||
`${LOG_PREFIX}: Snapshot upload failed after deleting server data!`,
|
||||
uploadError,
|
||||
);
|
||||
|
||||
// Use providerManager.setProviderConfig() to update both the stored config
|
||||
// AND the currentProviderPrivateCfg$ observable for proper form state
|
||||
const revertConfig = {
|
||||
...existingCfg,
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
} as SuperSyncPrivateCfg;
|
||||
await this._providerManager.setProviderConfig(
|
||||
SyncProviderId.SuperSync,
|
||||
revertConfig,
|
||||
);
|
||||
|
||||
// Clear cached adapters since encryption settings were reverted
|
||||
this._wrappedProviderService.clearCache();
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -97,7 +97,10 @@ export class EncryptionPasswordChangeService {
|
|||
// STEP 1: Create clean slate locally
|
||||
// This generates a new client ID, clears local ops, and creates a fresh SYNC_IMPORT
|
||||
SyncLog.normal('EncryptionPasswordChangeService: Creating clean slate...');
|
||||
await this._cleanSlateService.createCleanSlate('ENCRYPTION_CHANGE');
|
||||
await this._cleanSlateService.createCleanSlate(
|
||||
'ENCRYPTION_CHANGE',
|
||||
'PASSWORD_CHANGED',
|
||||
);
|
||||
|
||||
// STEP 2: Verify the SYNC_IMPORT was stored
|
||||
// This catches any IndexedDB timing issues before we proceed
|
||||
|
|
@ -115,7 +118,7 @@ export class EncryptionPasswordChangeService {
|
|||
// STEP 3: Update config with new password BEFORE upload
|
||||
// This ensures the upload will use the new password for encryption
|
||||
SyncLog.normal('EncryptionPasswordChangeService: Updating encryption config...');
|
||||
await syncProvider.setPrivateCfg({
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, {
|
||||
...existingCfg,
|
||||
encryptKey: newPassword,
|
||||
isEncryptionEnabled: true,
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import {
|
||||
EncryptionPasswordDialogOpenerService,
|
||||
setDialogOpenerInstance,
|
||||
} from './encryption-password-dialog-opener.service';
|
||||
|
||||
/**
|
||||
* Initialization service to set up the dialog opener instance.
|
||||
* This service should be injected in the app component or root module
|
||||
* to ensure the dialog opener is available for static form config functions.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class EncryptionPasswordDialogOpenerInitService {
|
||||
private _dialogOpener = inject(EncryptionPasswordDialogOpenerService);
|
||||
|
||||
constructor() {
|
||||
// Initialize the module-level reference so that exported functions work
|
||||
setDialogOpenerInstance(this._dialogOpener);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,10 +10,31 @@ import {
|
|||
EnableEncryptionDialogData,
|
||||
EnableEncryptionResult,
|
||||
} from './dialog-enable-encryption/dialog-enable-encryption.component';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
// Module-level reference, set by the service constructor
|
||||
let dialogOpenerInstance: EncryptionPasswordDialogOpenerService | null = null;
|
||||
|
||||
const setInstance = (instance: EncryptionPasswordDialogOpenerService): void => {
|
||||
dialogOpenerInstance = instance;
|
||||
};
|
||||
|
||||
const callOpener = <T>(fn: (opener: EncryptionPasswordDialogOpenerService) => T): T => {
|
||||
if (!dialogOpenerInstance) {
|
||||
throw new Error(
|
||||
'EncryptionPasswordDialogOpenerService not initialized. ' +
|
||||
'Ensure the service is injected before calling dialog functions.',
|
||||
);
|
||||
}
|
||||
return fn(dialogOpenerInstance);
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton service to open the encryption password change dialog.
|
||||
* Used by the sync form config which doesn't have direct access to injector.
|
||||
*
|
||||
* The constructor self-registers the module-level reference so that
|
||||
* exported functions work from static form config handlers.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -21,10 +42,11 @@ import {
|
|||
export class EncryptionPasswordDialogOpenerService {
|
||||
private _matDialog = inject(MatDialog);
|
||||
|
||||
/**
|
||||
* Closes all open dialogs. Useful after disabling encryption
|
||||
* to close the parent settings dialog.
|
||||
*/
|
||||
constructor() {
|
||||
// Self-register so module-level functions can delegate to this instance
|
||||
setInstance(this);
|
||||
}
|
||||
|
||||
closeAllDialogs(): void {
|
||||
this._matDialog.closeAll();
|
||||
}
|
||||
|
|
@ -39,160 +61,42 @@ export class EncryptionPasswordDialogOpenerService {
|
|||
data: { mode, providerType } as ChangeEncryptionPasswordDialogData,
|
||||
});
|
||||
|
||||
return dialogRef.afterClosed().toPromise();
|
||||
return firstValueFrom(dialogRef.afterClosed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the unified change password dialog in disable-only mode.
|
||||
* @deprecated Use openChangePasswordDialog('disable-only') instead
|
||||
*/
|
||||
openDisableEncryptionDialog(
|
||||
openEnableEncryptionDialog(
|
||||
providerType: 'supersync' | 'file-based' = 'supersync',
|
||||
): Promise<ChangeEncryptionPasswordResult | undefined> {
|
||||
return this.openChangePasswordDialog('disable-only', providerType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the disable encryption dialog for file-based providers.
|
||||
*/
|
||||
openDisableEncryptionDialogForFileBased(): Promise<
|
||||
ChangeEncryptionPasswordResult | undefined
|
||||
> {
|
||||
return this.openChangePasswordDialog('disable-only', 'file-based');
|
||||
}
|
||||
|
||||
openChangePasswordDialogForFileBased(): Promise<
|
||||
ChangeEncryptionPasswordResult | undefined
|
||||
> {
|
||||
return this.openChangePasswordDialog('full', 'file-based');
|
||||
}
|
||||
|
||||
openEnableEncryptionDialog(): Promise<EnableEncryptionResult | undefined> {
|
||||
): Promise<EnableEncryptionResult | undefined> {
|
||||
const dialogRef = this._matDialog.open(DialogEnableEncryptionComponent, {
|
||||
width: '450px',
|
||||
disableClose: true,
|
||||
data: { providerType: 'supersync' } as EnableEncryptionDialogData,
|
||||
data: { providerType } as EnableEncryptionDialogData,
|
||||
});
|
||||
|
||||
return dialogRef.afterClosed().toPromise();
|
||||
}
|
||||
|
||||
openEnableEncryptionDialogForFileBased(): Promise<EnableEncryptionResult | undefined> {
|
||||
const dialogRef = this._matDialog.open(DialogEnableEncryptionComponent, {
|
||||
width: '450px',
|
||||
disableClose: true,
|
||||
data: { providerType: 'file-based' } as EnableEncryptionDialogData,
|
||||
});
|
||||
|
||||
return dialogRef.afterClosed().toPromise();
|
||||
return firstValueFrom(dialogRef.afterClosed());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
> => callOpener((o) => o.openChangePasswordDialog());
|
||||
|
||||
/**
|
||||
* Opens the encryption password change dialog for file-based providers.
|
||||
*/
|
||||
export const openEncryptionPasswordChangeDialogForFileBased = (): Promise<
|
||||
ChangeEncryptionPasswordResult | undefined
|
||||
> => {
|
||||
if (!dialogOpenerInstance) {
|
||||
console.error('EncryptionPasswordDialogOpenerService not initialized');
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return dialogOpenerInstance.openChangePasswordDialogForFileBased();
|
||||
};
|
||||
> => callOpener((o) => o.openChangePasswordDialog('full', 'file-based'));
|
||||
|
||||
/**
|
||||
* Opens the disable encryption confirmation dialog.
|
||||
* Can be called from form config onChange handlers.
|
||||
*/
|
||||
export const openDisableEncryptionDialog = (): Promise<
|
||||
ChangeEncryptionPasswordResult | undefined
|
||||
> => {
|
||||
if (!dialogOpenerInstance) {
|
||||
console.error('EncryptionPasswordDialogOpenerService not initialized');
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return dialogOpenerInstance.openDisableEncryptionDialog();
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens the enable encryption confirmation dialog.
|
||||
* Can be called from form config onChange handlers.
|
||||
*/
|
||||
export const openEnableEncryptionDialog = (): Promise<
|
||||
EnableEncryptionResult | undefined
|
||||
> => {
|
||||
if (!dialogOpenerInstance) {
|
||||
console.error('EncryptionPasswordDialogOpenerService not initialized');
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return dialogOpenerInstance.openEnableEncryptionDialog();
|
||||
};
|
||||
> => callOpener((o) => o.openEnableEncryptionDialog());
|
||||
|
||||
/**
|
||||
* Opens the enable encryption dialog for file-based providers.
|
||||
*/
|
||||
export const openEnableEncryptionDialogForFileBased = (): Promise<
|
||||
EnableEncryptionResult | undefined
|
||||
> => {
|
||||
if (!dialogOpenerInstance) {
|
||||
console.error('EncryptionPasswordDialogOpenerService not initialized');
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return dialogOpenerInstance.openEnableEncryptionDialogForFileBased();
|
||||
};
|
||||
> => callOpener((o) => o.openEnableEncryptionDialog('file-based'));
|
||||
|
||||
/**
|
||||
* Opens the disable encryption dialog for file-based providers.
|
||||
* Can be called from form config onClick handlers.
|
||||
*/
|
||||
export const openDisableEncryptionDialogForFileBased = (): Promise<
|
||||
ChangeEncryptionPasswordResult | undefined
|
||||
> => {
|
||||
if (!dialogOpenerInstance) {
|
||||
console.error('EncryptionPasswordDialogOpenerService not initialized');
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
return dialogOpenerInstance.openDisableEncryptionDialogForFileBased();
|
||||
};
|
||||
> => callOpener((o) => o.openChangePasswordDialog('disable-only', 'file-based'));
|
||||
|
||||
/**
|
||||
* Closes all open dialogs. Useful after disabling encryption
|
||||
* to close the parent settings dialog.
|
||||
*/
|
||||
export const closeAllDialogs = (): void => {
|
||||
if (!dialogOpenerInstance) {
|
||||
console.error('EncryptionPasswordDialogOpenerService not initialized');
|
||||
return;
|
||||
}
|
||||
dialogOpenerInstance.closeAllDialogs();
|
||||
callOpener((o) => o.closeAllDialogs());
|
||||
};
|
||||
|
|
|
|||
|
|
@ -38,13 +38,19 @@ export class FileBasedEncryptionService {
|
|||
await this._applyEncryption(newPassword, 'change');
|
||||
}
|
||||
|
||||
async disableEncryption(): Promise<void> {
|
||||
await this._applyEncryption(undefined, 'disable');
|
||||
}
|
||||
|
||||
private async _applyEncryption(
|
||||
encryptKey: string,
|
||||
action: 'enable' | 'change',
|
||||
encryptKey: string | undefined,
|
||||
action: 'enable' | 'change' | 'disable',
|
||||
): Promise<void> {
|
||||
SyncLog.normal(`${LOG_PREFIX}: Starting ${action} for file-based provider...`);
|
||||
|
||||
if (!encryptKey) {
|
||||
const isDisable = action === 'disable';
|
||||
|
||||
if (!isDisable && !encryptKey) {
|
||||
throw new Error('Encryption password is required');
|
||||
}
|
||||
|
||||
|
|
@ -75,15 +81,15 @@ export class FileBasedEncryptionService {
|
|||
const existingCfg = await provider.privateCfg.load();
|
||||
|
||||
const baseCfg = this._providerManager.getEncryptAndCompressCfg();
|
||||
const encryptedCfg = {
|
||||
const adapterCfg = {
|
||||
...baseCfg,
|
||||
isEncrypt: true,
|
||||
isEncrypt: !isDisable,
|
||||
};
|
||||
|
||||
const adapter = this._fileBasedAdapter.createAdapter(
|
||||
provider,
|
||||
encryptedCfg,
|
||||
encryptKey,
|
||||
adapterCfg,
|
||||
isDisable ? undefined : encryptKey,
|
||||
);
|
||||
|
||||
const result = await adapter.uploadSnapshot(
|
||||
|
|
@ -92,7 +98,7 @@ export class FileBasedEncryptionService {
|
|||
'recovery',
|
||||
vectorClock,
|
||||
CURRENT_SCHEMA_VERSION,
|
||||
true,
|
||||
!isDisable,
|
||||
uuidv7(),
|
||||
);
|
||||
|
||||
|
|
@ -100,15 +106,36 @@ export class FileBasedEncryptionService {
|
|||
throw new Error(`Snapshot upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
const newConfig = {
|
||||
...existingCfg,
|
||||
encryptKey,
|
||||
};
|
||||
await this._providerManager.setProviderConfig(provider.id, newConfig);
|
||||
|
||||
this._globalConfigService.updateSection('sync', {
|
||||
isEncryptionEnabled: true,
|
||||
});
|
||||
try {
|
||||
if (isDisable) {
|
||||
// Use providerManager.setProviderConfig() instead of direct setPrivateCfg()
|
||||
// to ensure the currentProviderPrivateCfg$ observable is updated
|
||||
await this._providerManager.setProviderConfig(provider.id, {
|
||||
...existingCfg,
|
||||
encryptKey: undefined,
|
||||
});
|
||||
this._globalConfigService.updateSection('sync', {
|
||||
isEncryptionEnabled: false,
|
||||
encryptKey: '',
|
||||
});
|
||||
} else {
|
||||
await this._providerManager.setProviderConfig(provider.id, {
|
||||
...existingCfg,
|
||||
encryptKey,
|
||||
});
|
||||
this._globalConfigService.updateSection('sync', {
|
||||
isEncryptionEnabled: true,
|
||||
});
|
||||
}
|
||||
} catch (cfgError) {
|
||||
SyncLog.err(
|
||||
`${LOG_PREFIX}: Failed to update config after successful upload. ` +
|
||||
`Server has ${isDisable ? 'unencrypted' : 'encrypted'} data but local config may be stale. ` +
|
||||
`Please update encryption settings manually.`,
|
||||
cfgError,
|
||||
);
|
||||
throw cfgError;
|
||||
}
|
||||
|
||||
this._derivedKeyCache.clearCache();
|
||||
this._wrappedProviderService.clearCache();
|
||||
|
|
|
|||
|
|
@ -147,10 +147,12 @@ export class ImportEncryptionHandlerService {
|
|||
};
|
||||
}
|
||||
|
||||
let serverDataDeleted = false;
|
||||
try {
|
||||
// 1. Delete all server data (encrypted ops can't mix with unencrypted)
|
||||
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
|
||||
await syncProvider.deleteAllData();
|
||||
serverDataDeleted = true;
|
||||
|
||||
// 2. Update sync provider config with new encryption settings BEFORE upload
|
||||
// IMPORTANT: Use providerManager.setProviderConfig() instead of direct setPrivateCfg()
|
||||
|
|
@ -212,7 +214,7 @@ export class ImportEncryptionHandlerService {
|
|||
|
||||
return {
|
||||
encryptionStateChanged: true,
|
||||
serverDataDeleted: false,
|
||||
serverDataDeleted,
|
||||
snapshotUploaded: false,
|
||||
error: errorMessage,
|
||||
};
|
||||
|
|
@ -237,6 +239,14 @@ export class ImportEncryptionHandlerService {
|
|||
return null;
|
||||
}
|
||||
|
||||
// Never let imports disable encryption — encryption is mandatory for SuperSync
|
||||
if (checkResult.currentEnabled && !checkResult.importedEnabled) {
|
||||
SyncLog.normal(
|
||||
'ImportEncryptionHandlerService: Import would disable encryption — skipping',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
SyncLog.normal('ImportEncryptionHandlerService: Encryption state change detected', {
|
||||
from: checkResult.currentEnabled ? 'encrypted' : 'unencrypted',
|
||||
to: checkResult.importedEnabled ? 'encrypted' : 'unencrypted',
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import { GlobalConfigState } from '../../features/config/global-config.model';
|
||||
import { TaskArchive, TaskState } from '../../features/tasks/task.model';
|
||||
import { Reminder } from '../../features/reminder/reminder.model';
|
||||
import { MetricState } from '../../features/metric/metric.model';
|
||||
import { TaskRepeatCfgState } from '../../features/task-repeat-cfg/task-repeat-cfg.model';
|
||||
import { TagState } from '../../features/tag/tag.model';
|
||||
import { SimpleCounterState } from '../../features/simple-counter/simple-counter.model';
|
||||
import { ProjectArchive } from '../../features/project/project-archive.model';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { ProjectState } from '../../features/project/project.model';
|
||||
import { NoteState } from '../../features/note/note.model';
|
||||
import { LocalSyncMetaForProvider } from './sync.model';
|
||||
|
||||
// TODO remove completely, if not migrating anymore
|
||||
|
||||
/** @deprecated */
|
||||
export interface LegacyAppBaseData {
|
||||
project: ProjectState;
|
||||
archivedProjects: ProjectArchive;
|
||||
globalConfig: GlobalConfigState;
|
||||
reminders: Reminder[];
|
||||
|
||||
metric: MetricState;
|
||||
|
||||
task: TaskState;
|
||||
tag: TagState;
|
||||
simpleCounter: SimpleCounterState;
|
||||
taskArchive: TaskArchive;
|
||||
taskRepeatCfg: TaskRepeatCfgState;
|
||||
}
|
||||
|
||||
export interface LocalSyncMetaModel {
|
||||
[SyncProviderId.WebDAV]: LocalSyncMetaForProvider;
|
||||
[SyncProviderId.Dropbox]: LocalSyncMetaForProvider;
|
||||
[SyncProviderId.LocalFile]: LocalSyncMetaForProvider;
|
||||
}
|
||||
|
||||
// NOTE: [key:string] always refers to projectId
|
||||
export interface LegacyAppDataForProjects {
|
||||
note: {
|
||||
[key: string]: NoteState;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LegacyAppDataCompleteOptionalSyncModelChange
|
||||
extends LegacyAppBaseData, LegacyAppDataForProjects {
|
||||
lastLocalSyncModelChange?: number | null;
|
||||
}
|
||||
|
||||
export interface LegacyAppDataComplete
|
||||
extends LegacyAppBaseData, LegacyAppDataForProjects {
|
||||
lastLocalSyncModelChange: number | null;
|
||||
}
|
||||
|
|
@ -53,8 +53,7 @@ export interface SnapshotUploadResult {
|
|||
* Config orchestration (timing, error recovery, encryption settings)
|
||||
* remains the responsibility of calling services.
|
||||
*
|
||||
* @see EncryptionDisableService
|
||||
* @see EncryptionEnableService
|
||||
* @see SuperSyncEncryptionToggleService
|
||||
* @see ImportEncryptionHandlerService
|
||||
*/
|
||||
@Injectable({
|
||||
|
|
|
|||
190
src/app/imex/sync/supersync-encryption-toggle.service.ts
Normal file
190
src/app/imex/sync/supersync-encryption-toggle.service.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { SnapshotUploadService } from './snapshot-upload.service';
|
||||
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
|
||||
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
|
||||
import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption';
|
||||
import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors';
|
||||
|
||||
const LOG_PREFIX = 'SuperSyncEncryptionToggleService';
|
||||
|
||||
/**
|
||||
* Service for enabling/disabling encryption for SuperSync.
|
||||
*
|
||||
* Enable flow: encrypt first, then delete, then upload (fail-early on encryption errors)
|
||||
* Disable flow: delete, then upload unencrypted, then update config
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SuperSyncEncryptionToggleService {
|
||||
private _snapshotUploadService = inject(SnapshotUploadService);
|
||||
private _encryptionService = inject(OperationEncryptionService);
|
||||
private _wrappedProviderService = inject(WrappedProviderService);
|
||||
private _providerManager = inject(SyncProviderManager);
|
||||
|
||||
/**
|
||||
* Enables encryption:
|
||||
* 1. Encrypt snapshot (fail-early before any destructive action)
|
||||
* 2. Delete all server data
|
||||
* 3. Update config BEFORE upload so the upload uses the new key
|
||||
* 4. Upload encrypted snapshot (reverts config on failure)
|
||||
*/
|
||||
async enableEncryption(encryptKey: string): Promise<void> {
|
||||
SyncLog.normal(`${LOG_PREFIX}: Starting encryption enable...`);
|
||||
|
||||
if (!encryptKey) {
|
||||
throw new Error('Encryption key is required');
|
||||
}
|
||||
|
||||
// Guard against concurrent calls
|
||||
const activeProvider = this._providerManager.getActiveProvider();
|
||||
if (activeProvider) {
|
||||
const currentCfg = (await activeProvider.privateCfg.load()) as
|
||||
| { isEncryptionEnabled?: boolean; encryptKey?: string }
|
||||
| undefined;
|
||||
if (currentCfg?.isEncryptionEnabled && currentCfg?.encryptKey) {
|
||||
SyncLog.normal(
|
||||
`${LOG_PREFIX}: Encryption is already enabled, skipping duplicate enableEncryption call`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check crypto availability BEFORE deleting server data
|
||||
if (!isCryptoSubtleAvailable()) {
|
||||
throw new WebCryptoNotAvailableError(
|
||||
'Cannot enable encryption: WebCrypto API is not available. ' +
|
||||
'Encryption requires a secure context (HTTPS). ' +
|
||||
'On Android, encryption is not supported.',
|
||||
);
|
||||
}
|
||||
|
||||
const { syncProvider, existingCfg, state, vectorClock, clientId } =
|
||||
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
|
||||
|
||||
// Encrypt BEFORE deleting server data to fail early if encryption fails
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encrypting snapshot...`);
|
||||
const encryptedPayload = await this._encryptionService.encryptPayload(
|
||||
state,
|
||||
encryptKey,
|
||||
);
|
||||
|
||||
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
|
||||
await syncProvider.deleteAllData();
|
||||
|
||||
try {
|
||||
// Update config BEFORE upload so the upload uses the new key
|
||||
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
|
||||
const newConfig = {
|
||||
...existingCfg,
|
||||
encryptKey,
|
||||
isEncryptionEnabled: true,
|
||||
} as SuperSyncPrivateCfg;
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, newConfig);
|
||||
|
||||
this._wrappedProviderService.clearCache();
|
||||
|
||||
await this._uploadAndFinalize(
|
||||
syncProvider,
|
||||
encryptedPayload,
|
||||
clientId,
|
||||
vectorClock,
|
||||
true,
|
||||
);
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encryption enabled successfully!`);
|
||||
} catch (uploadError) {
|
||||
// Revert config on failure
|
||||
SyncLog.err(`${LOG_PREFIX}: Failed after deleting server data!`, uploadError);
|
||||
|
||||
const revertConfig = {
|
||||
...existingCfg,
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
} as SuperSyncPrivateCfg;
|
||||
await this._providerManager.setProviderConfig(
|
||||
SyncProviderId.SuperSync,
|
||||
revertConfig,
|
||||
);
|
||||
this._wrappedProviderService.clearCache();
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disables encryption by deleting all server data and uploading an unencrypted snapshot.
|
||||
* Config is updated AFTER successful upload for safety.
|
||||
*/
|
||||
async disableEncryption(): Promise<void> {
|
||||
SyncLog.normal(`${LOG_PREFIX}: Starting encryption disable...`);
|
||||
|
||||
const { syncProvider, existingCfg, state, vectorClock, clientId } =
|
||||
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
|
||||
|
||||
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
|
||||
await syncProvider.deleteAllData();
|
||||
|
||||
SyncLog.normal(`${LOG_PREFIX}: Uploading unencrypted snapshot...`);
|
||||
try {
|
||||
await this._uploadAndFinalize(syncProvider, state, clientId, vectorClock, false);
|
||||
|
||||
// Update config AFTER successful upload
|
||||
// IMPORTANT: Use providerManager.setProviderConfig() instead of direct setPrivateCfg()
|
||||
// to ensure the currentProviderPrivateCfg$ observable is updated.
|
||||
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
|
||||
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, {
|
||||
...existingCfg,
|
||||
encryptKey: undefined,
|
||||
isEncryptionEnabled: false,
|
||||
} as SuperSyncPrivateCfg);
|
||||
|
||||
this._wrappedProviderService.clearCache();
|
||||
SyncLog.normal(`${LOG_PREFIX}: Encryption disabled successfully!`);
|
||||
} catch (uploadError) {
|
||||
SyncLog.err(
|
||||
`${LOG_PREFIX}: Snapshot upload failed after deleting server data!`,
|
||||
uploadError,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
'CRITICAL: Failed to upload unencrypted snapshot after deleting server data. ' +
|
||||
'Your local data is safe. Encryption is still enabled. Please try disabling encryption again. ' +
|
||||
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _uploadAndFinalize(
|
||||
syncProvider: Parameters<SnapshotUploadService['uploadSnapshot']>[0],
|
||||
payload: unknown,
|
||||
clientId: string,
|
||||
vectorClock: Record<string, number>,
|
||||
isPayloadEncrypted: boolean,
|
||||
): Promise<void> {
|
||||
const result = await this._snapshotUploadService.uploadSnapshot(
|
||||
syncProvider,
|
||||
payload,
|
||||
clientId,
|
||||
vectorClock,
|
||||
isPayloadEncrypted,
|
||||
);
|
||||
|
||||
if (!result.accepted) {
|
||||
throw new Error(`Snapshot upload failed: ${result.error}`);
|
||||
}
|
||||
|
||||
await this._snapshotUploadService.updateLastServerSeq(
|
||||
syncProvider,
|
||||
result.serverSeq,
|
||||
LOG_PREFIX,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { SyncLog } from '../../core/log';
|
|||
import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cache.service';
|
||||
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
|
||||
import { SyncWrapperService } from './sync-wrapper.service';
|
||||
|
||||
// Maps sync providers to their corresponding form field in SyncConfig
|
||||
// Dropbox is null because it doesn't store settings in the form (uses OAuth)
|
||||
|
|
@ -92,6 +93,7 @@ export class SyncConfigService {
|
|||
private _globalConfigService = inject(GlobalConfigService);
|
||||
private _derivedKeyCache = inject(DerivedKeyCacheService);
|
||||
private _wrappedProvider = inject(WrappedProviderService);
|
||||
private _syncWrapper = inject(SyncWrapperService);
|
||||
|
||||
private _lastSettings: SyncConfig | null = null;
|
||||
|
||||
|
|
@ -161,7 +163,12 @@ export class SyncConfigService {
|
|||
if (!currentProviderCfg) {
|
||||
return from(
|
||||
fetch('/assets/sync-config-default-override.json')
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((defaultOverride) => {
|
||||
return {
|
||||
...baseConfig,
|
||||
|
|
@ -212,8 +219,11 @@ export class SyncConfigService {
|
|||
|
||||
return of(result);
|
||||
}),
|
||||
// Redact sensitive fields (passwords, encryption keys) in all environments
|
||||
tap((v) => SyncLog.log('syncSettingsForm$', redactSensitiveFields(v))),
|
||||
// Keep _lastSettings in sync so Formly modelChange doesn't trigger redundant saves
|
||||
tap((v) => {
|
||||
this._lastSettings = v;
|
||||
SyncLog.log('syncSettingsForm$', redactSensitiveFields(v));
|
||||
}),
|
||||
);
|
||||
|
||||
async updateEncryptionPassword(
|
||||
|
|
@ -246,13 +256,12 @@ export class SyncConfigService {
|
|||
|
||||
await this._providerManager.setProviderConfig(activeProvider.id, newConfig);
|
||||
|
||||
// Ensure global config reflects encryption enabled when password is entered
|
||||
this._globalConfigService.updateSection('sync', { isEncryptionEnabled: true });
|
||||
|
||||
// Clear cached encryption keys to force re-derivation with new password
|
||||
this._derivedKeyCache.clearCache();
|
||||
// Clear cached adapters to force recreation with new encryption settings
|
||||
this._wrappedProvider.clearCache();
|
||||
// Allow encryption dialogs to appear again after password change
|
||||
this._syncWrapper.clearEncryptionDialogSuppression();
|
||||
}
|
||||
|
||||
async updateSettingsFromForm(newSettings: SyncConfig, isForce = false): Promise<void> {
|
||||
|
|
@ -331,7 +340,7 @@ export class SyncConfigService {
|
|||
{} as Record<string, unknown>,
|
||||
);
|
||||
|
||||
// The provider's saved config is the source of truth after EncryptionDisableService runs
|
||||
// The provider's saved config is the source of truth after SuperSyncEncryptionToggleService runs
|
||||
// oldConfig is loaded from activeProvider.privateCfg.load()
|
||||
// When encryption is explicitly disabled, we must clear encryptKey regardless of form state
|
||||
const isEncryptionDisabledInSavedConfig =
|
||||
|
|
|
|||
|
|
@ -48,18 +48,20 @@ import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-
|
|||
import { DialogConflictResolutionResult } from './sync.model';
|
||||
import { DialogSyncConflictComponent } from './dialog-sync-conflict/dialog-sync-conflict.component';
|
||||
import { ReminderService } from '../../features/reminder/reminder.service';
|
||||
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 {
|
||||
DialogSyncErrorComponent,
|
||||
DialogSyncErrorResult,
|
||||
} from './dialog-sync-error/dialog-sync-error.component';
|
||||
import { SyncLog } from '../../core/log';
|
||||
import { promiseTimeout } from '../../util/promise-timeout';
|
||||
import { devError } from '../../util/dev-error';
|
||||
import { alertDialog, confirmDialog } from '../../util/native-dialogs';
|
||||
import { UserInputWaitStateService } from './user-input-wait-state.service';
|
||||
import { SYNC_WAIT_TIMEOUT_MS, SYNC_REINIT_DELAY_MS } from './sync.const';
|
||||
import { SYNC_WAIT_TIMEOUT_MS } from './sync.const';
|
||||
import { SuperSyncStatusService } from '../../op-log/sync/super-sync-status.service';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service';
|
||||
|
|
@ -76,7 +78,7 @@ export class SyncWrapperService {
|
|||
private _translateService = inject(TranslateService);
|
||||
private _snackService = inject(SnackService);
|
||||
private _matDialog = inject(MatDialog);
|
||||
private _dataInitService = inject(DataInitService);
|
||||
|
||||
private _reminderService = inject(ReminderService);
|
||||
private _userInputWaitState = inject(UserInputWaitStateService);
|
||||
private _superSyncStatusService = inject(SuperSyncStatusService);
|
||||
|
|
@ -115,6 +117,13 @@ export class SyncWrapperService {
|
|||
*/
|
||||
private _isEncryptionOperationInProgress$ = new BehaviorSubject(false);
|
||||
|
||||
/**
|
||||
* When true, encryption-related dialogs (missing password, decrypt error) are suppressed.
|
||||
* Set after the user cancels a dialog so they can navigate to settings to change the password
|
||||
* without being blocked by recurring auto-sync dialogs. Cleared when encryption config changes.
|
||||
*/
|
||||
private _suppressEncryptionDialogs = false;
|
||||
|
||||
/**
|
||||
* Observable for UI: true when all local changes have been uploaded.
|
||||
* Used for all sync providers to show the single checkmark indicator.
|
||||
|
|
@ -144,6 +153,14 @@ export class SyncWrapperService {
|
|||
return this._isEncryptionOperationInProgress$.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the suppression flag so encryption dialogs can appear again.
|
||||
* Called after the user changes the encryption password via settings.
|
||||
*/
|
||||
clearEncryptionDialogSuppression(): void {
|
||||
this._suppressEncryptionDialogs = false;
|
||||
}
|
||||
|
||||
// Expose shared user input wait state for other services (e.g., SyncTriggerService)
|
||||
isWaitingForUserInput$ = this._userInputWaitState.isWaitingForUserInput$;
|
||||
|
||||
|
|
@ -190,7 +207,7 @@ export class SyncWrapperService {
|
|||
this._isSyncInProgress$.next(true);
|
||||
// Set SYNCING status so ImmediateUploadService knows not to interfere
|
||||
this._providerManager.setSyncStatus('SYNCING');
|
||||
return this._sync().finally(() => {
|
||||
const result = await this._sync().finally(() => {
|
||||
this._isSyncInProgress$.next(false);
|
||||
// Safeguard: if _sync() threw or completed without setting a final status,
|
||||
// reset from SYNCING to UNKNOWN_OR_CHANGED to avoid getting stuck in SYNCING state
|
||||
|
|
@ -198,6 +215,16 @@ export class SyncWrapperService {
|
|||
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
||||
}
|
||||
});
|
||||
|
||||
// After successful sync, prompt for encryption if SuperSync is active without it.
|
||||
// This ensures data is downloaded and merged first, preventing data loss.
|
||||
if (result === SyncStatus.InSync) {
|
||||
this._promptSuperSyncEncryptionIfNeeded().catch((err) => {
|
||||
SyncLog.err('Error prompting for encryption:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -229,7 +256,9 @@ export class SyncWrapperService {
|
|||
}),
|
||||
]);
|
||||
} catch (e) {
|
||||
SyncLog.warn('Timeout waiting for sync to complete, proceeding anyway');
|
||||
throw new Error(
|
||||
'Cannot change encryption settings: sync timed out. Please try again.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,7 +317,7 @@ export class SyncWrapperService {
|
|||
if (downloadResult.cancelled) {
|
||||
SyncLog.log('SyncWrapperService: Sync cancelled by user. Skipping upload phase.');
|
||||
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
||||
return SyncStatus.NotConfigured;
|
||||
return 'HANDLED_ERROR';
|
||||
}
|
||||
|
||||
// 2. Upload pending local ops
|
||||
|
|
@ -300,6 +329,15 @@ export class SyncWrapperService {
|
|||
);
|
||||
}
|
||||
|
||||
// If upload was cancelled (piggybacked SYNC_IMPORT conflict dialog), skip LWW re-upload
|
||||
if (uploadResult?.cancelled) {
|
||||
SyncLog.log(
|
||||
'SyncWrapperService: Upload cancelled by user (piggybacked SYNC_IMPORT). Skipping LWW re-upload.',
|
||||
);
|
||||
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
||||
return 'HANDLED_ERROR';
|
||||
}
|
||||
|
||||
// 3. If LWW created local-win ops, upload them (with retry limit to prevent infinite loops)
|
||||
let lwwRetries = 0;
|
||||
let pendingLwwOps =
|
||||
|
|
@ -528,6 +566,36 @@ export class SyncWrapperService {
|
|||
});
|
||||
}
|
||||
|
||||
private async _forceDownload(): Promise<void> {
|
||||
SyncLog.log('SyncWrapperService: forceDownload called - downloading remote state');
|
||||
|
||||
await this.runWithSyncBlocked(async () => {
|
||||
try {
|
||||
const rawProvider = this._providerManager.getActiveProvider();
|
||||
const syncCapableProvider =
|
||||
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
|
||||
|
||||
if (!syncCapableProvider) {
|
||||
SyncLog.warn(
|
||||
'SyncWrapperService: Cannot force download - provider not available',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider);
|
||||
this._providerManager.setSyncStatus('IN_SYNC');
|
||||
SyncLog.log('SyncWrapperService: Force download complete');
|
||||
} catch (error) {
|
||||
SyncLog.err('SyncWrapperService: Force download failed:', error);
|
||||
const errStr = getSyncErrorStr(error);
|
||||
this._snackService.open({
|
||||
msg: errStr,
|
||||
type: 'ERROR',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async configuredAuthForSyncProviderIfNecessary(
|
||||
providerId: SyncProviderId,
|
||||
force = false,
|
||||
|
|
@ -596,52 +664,33 @@ export class SyncWrapperService {
|
|||
* Uses fire-and-forget pattern but logs errors instead of swallowing them.
|
||||
*/
|
||||
private _handleIncoherentTimestampsDialog(): void {
|
||||
const dialogRef = this._matDialog.open(DialogIncoherentTimestampsErrorComponent, {
|
||||
this._openSyncErrorDialog({ type: 'incoherent-timestamps' });
|
||||
}
|
||||
|
||||
private _handleIncompleteSyncDialog(modelId: string | undefined): void {
|
||||
this._openSyncErrorDialog({ type: 'incomplete-sync', modelId });
|
||||
}
|
||||
|
||||
private _openSyncErrorDialog(data: {
|
||||
type: 'incomplete-sync' | 'incoherent-timestamps';
|
||||
modelId?: string;
|
||||
}): void {
|
||||
const dialogRef = this._matDialog.open(DialogSyncErrorComponent, {
|
||||
data,
|
||||
disableClose: true,
|
||||
autoFocus: false,
|
||||
});
|
||||
|
||||
// Use firstValueFrom for proper async handling
|
||||
firstValueFrom(dialogRef.afterClosed())
|
||||
.then(async (res) => {
|
||||
.then(async (res: DialogSyncErrorResult) => {
|
||||
if (res === 'FORCE_UPDATE_REMOTE') {
|
||||
await this.forceUpload();
|
||||
} else if (res === 'FORCE_UPDATE_LOCAL') {
|
||||
// Op-log architecture handles this differently
|
||||
SyncLog.log(
|
||||
'SyncWrapperService: forceDownload called (delegated to op-log sync)',
|
||||
);
|
||||
await this._forceDownload();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
SyncLog.err('Error handling incoherent timestamps dialog result:', err);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.SYNC.S.DIALOG_RESULT_ERROR,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incomplete sync dialog with proper async error handling.
|
||||
* Uses fire-and-forget pattern but logs errors instead of swallowing them.
|
||||
*/
|
||||
private _handleIncompleteSyncDialog(modelId: string | undefined): void {
|
||||
const dialogRef = this._matDialog.open(DialogIncompleteSyncComponent, {
|
||||
data: { modelId },
|
||||
disableClose: true,
|
||||
autoFocus: false,
|
||||
});
|
||||
|
||||
// Use firstValueFrom for proper async handling
|
||||
firstValueFrom(dialogRef.afterClosed())
|
||||
.then(async (res) => {
|
||||
if (res === 'FORCE_UPDATE_REMOTE') {
|
||||
await this.forceUpload();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
SyncLog.err('Error handling incomplete sync dialog result:', err);
|
||||
SyncLog.err('Error handling sync error dialog result:', err);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.F.SYNC.S.DIALOG_RESULT_ERROR,
|
||||
|
|
@ -674,6 +723,12 @@ export class SyncWrapperService {
|
|||
* Opens a simple dialog to prompt for the password, then re-syncs.
|
||||
*/
|
||||
private _handleMissingPasswordDialog(): void {
|
||||
// Suppress dialog if user previously cancelled (so they can navigate to settings)
|
||||
if (this._suppressEncryptionDialogs) {
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent multiple password dialogs from opening simultaneously
|
||||
if (this._passwordDialog) {
|
||||
return;
|
||||
|
|
@ -689,23 +744,37 @@ export class SyncWrapperService {
|
|||
autoFocus: false,
|
||||
});
|
||||
|
||||
this._passwordDialog.afterClosed().subscribe((result) => {
|
||||
this._passwordDialog = undefined;
|
||||
firstValueFrom(this._passwordDialog.afterClosed())
|
||||
.then((result) => {
|
||||
this._passwordDialog = undefined;
|
||||
|
||||
if (result?.password) {
|
||||
// Password was entered and saved, re-sync
|
||||
this.sync();
|
||||
} else if (result?.forceOverwrite) {
|
||||
// Force overwrite succeeded; reflect synced status
|
||||
this._providerManager.setSyncStatus('IN_SYNC');
|
||||
} else {
|
||||
// User cancelled - set status to unknown
|
||||
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
||||
}
|
||||
});
|
||||
if (result?.password) {
|
||||
// Password was entered and saved — clear suppression and re-sync
|
||||
this._suppressEncryptionDialogs = false;
|
||||
this.sync();
|
||||
} else if (result?.forceOverwrite) {
|
||||
// Force overwrite succeeded; reflect synced status
|
||||
this._suppressEncryptionDialogs = false;
|
||||
this._providerManager.setSyncStatus('IN_SYNC');
|
||||
} else {
|
||||
// User cancelled — suppress future dialogs so they can navigate to settings
|
||||
this._suppressEncryptionDialogs = true;
|
||||
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this._passwordDialog = undefined;
|
||||
SyncLog.err('Error handling missing password dialog result:', err);
|
||||
});
|
||||
}
|
||||
|
||||
private _handleDecryptionError(): void {
|
||||
// Suppress dialog if user previously cancelled (so they can navigate to settings)
|
||||
if (this._suppressEncryptionDialogs) {
|
||||
this._providerManager.setSyncStatus('ERROR');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent multiple password dialogs from opening simultaneously
|
||||
if (this._passwordDialog) {
|
||||
return;
|
||||
|
|
@ -727,26 +796,31 @@ export class SyncWrapperService {
|
|||
autoFocus: false,
|
||||
});
|
||||
|
||||
this._passwordDialog.afterClosed().subscribe(({ isReSync, isForceUpload }) => {
|
||||
this._passwordDialog = undefined;
|
||||
firstValueFrom(this._passwordDialog.afterClosed())
|
||||
.then((result) => {
|
||||
this._passwordDialog = undefined;
|
||||
|
||||
if (isReSync) {
|
||||
this.sync();
|
||||
}
|
||||
if (isForceUpload) {
|
||||
this.forceUpload();
|
||||
}
|
||||
// Reset status if user cancelled without taking action
|
||||
if (!isReSync && !isForceUpload) {
|
||||
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
||||
}
|
||||
});
|
||||
if (result?.isReSync) {
|
||||
this._suppressEncryptionDialogs = false;
|
||||
this.sync();
|
||||
} else if (result?.isForceUpload) {
|
||||
this._suppressEncryptionDialogs = false;
|
||||
this.forceUpload();
|
||||
} else {
|
||||
// User cancelled — suppress future dialogs so they can navigate to settings
|
||||
this._suppressEncryptionDialogs = true;
|
||||
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
this._passwordDialog = undefined;
|
||||
SyncLog.err('Error handling decrypt error dialog result:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles LocalDataConflictError by showing a conflict resolution dialog.
|
||||
* This occurs when file-based sync (Dropbox, WebDAV) detects local unsynced
|
||||
* changes that would be lost if the remote snapshot is applied.
|
||||
* This occurs when sync detects local data that would be overwritten by remote data.
|
||||
*
|
||||
* User can choose:
|
||||
* - USE_LOCAL: Upload local data, overwriting remote (uses forceUploadLocalState)
|
||||
|
|
@ -846,27 +920,6 @@ export class SyncWrapperService {
|
|||
}
|
||||
}
|
||||
|
||||
private async _reInitAppAfterDataModelChange(
|
||||
downloadedMainModelData?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
SyncLog.log('Starting data re-initialization after sync...');
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
// Use reInitFromRemoteSync() which now uses the passed downloaded data
|
||||
// instead of reading from IndexedDB (entity models aren't stored there)
|
||||
this._dataInitService.reInitFromRemoteSync(downloadedMainModelData),
|
||||
]);
|
||||
// wait an extra frame to potentially avoid follow up problems
|
||||
await promiseTimeout(SYNC_REINIT_DELAY_MS);
|
||||
SyncLog.log('Data re-initialization complete');
|
||||
// Signal that data reload is complete
|
||||
} catch (error) {
|
||||
SyncLog.err('Error during data re-initialization:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private _c(str: string): boolean {
|
||||
return confirmDialog(this._translateService.instant(str));
|
||||
}
|
||||
|
|
@ -895,13 +948,121 @@ export class SyncWrapperService {
|
|||
return T.F.SYNC.S.ERROR_PERMISSION;
|
||||
}
|
||||
|
||||
private lastConflictDialog?: MatDialogRef<any, any>;
|
||||
private lastConflictDialog?: MatDialogRef<
|
||||
DialogSyncConflictComponent,
|
||||
DialogConflictResolutionResult
|
||||
>;
|
||||
|
||||
/**
|
||||
* Reference to any open password-related dialog (enter password or decrypt error).
|
||||
* Used to prevent multiple simultaneous password dialogs from opening.
|
||||
* Uses Record<string, unknown> because dialog components are dynamically imported.
|
||||
*/
|
||||
private _passwordDialog?: MatDialogRef<any, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private _passwordDialog?: MatDialogRef<any, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Reference to the encryption-required dialog to prevent multiple opens.
|
||||
* Uses Record<string, unknown> because dialog component is dynamically imported.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private _encryptionRequiredDialog?: MatDialogRef<any, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* Synchronous guard to prevent TOCTOU race in _promptSuperSyncEncryptionIfNeeded.
|
||||
* The async import() between checking _encryptionRequiredDialog and opening the dialog
|
||||
* creates a window where concurrent calls can both pass the guard.
|
||||
*/
|
||||
private _isOpeningEncryptionDialog = false;
|
||||
|
||||
/**
|
||||
* After a successful sync, checks if SuperSync is active without encryption.
|
||||
* If so, opens the encryption dialog. Data has already been synced, so no data loss.
|
||||
*/
|
||||
private async _promptSuperSyncEncryptionIfNeeded(): Promise<void> {
|
||||
SyncLog.log(
|
||||
'_promptSuperSyncEncryptionIfNeeded called, _isOpeningEncryptionDialog=',
|
||||
this._isOpeningEncryptionDialog,
|
||||
', _encryptionRequiredDialog=',
|
||||
!!this._encryptionRequiredDialog,
|
||||
', openDialogs=',
|
||||
this._matDialog.openDialogs.length,
|
||||
);
|
||||
const providerId = await firstValueFrom(this.syncProviderId$);
|
||||
if (providerId !== SyncProviderId.SuperSync) {
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = this._providerManager.getActiveProvider();
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = (await provider.privateCfg.load()) as
|
||||
| { isEncryptionEnabled?: boolean; encryptKey?: string }
|
||||
| undefined;
|
||||
if (cfg?.isEncryptionEnabled && cfg?.encryptKey) {
|
||||
SyncLog.log('SuperSync encryption already enabled, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
SyncLog.log(
|
||||
'SuperSync encryption not enabled — prompting user, openDialogs=',
|
||||
this._matDialog.openDialogs.length,
|
||||
', _isOpeningEncryptionDialog=',
|
||||
this._isOpeningEncryptionDialog,
|
||||
', _encryptionRequiredDialog=',
|
||||
!!this._encryptionRequiredDialog,
|
||||
);
|
||||
|
||||
// Don't open if ANY dialog is already open. The config dialog's save() method
|
||||
// handles encryption setup (either "enable encryption" or "enter password" based
|
||||
// on a server probe). Opening a competing dialog causes duplicate encryption
|
||||
// prompts and can trigger unwanted clean-slate operations.
|
||||
if (this._matDialog.openDialogs.length > 0) {
|
||||
SyncLog.log('Dialog already open — skipping encryption prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._encryptionRequiredDialog && !this._isOpeningEncryptionDialog) {
|
||||
this._isOpeningEncryptionDialog = true;
|
||||
SyncLog.log('Opening encryption dialog (guard passed)');
|
||||
try {
|
||||
const { DialogEnableEncryptionComponent } =
|
||||
await import('./dialog-enable-encryption/dialog-enable-encryption.component');
|
||||
|
||||
// Double-check after async import: another call might have opened a dialog
|
||||
if (this._encryptionRequiredDialog || this._matDialog.openDialogs.length > 0) {
|
||||
SyncLog.log('Dialog appeared during import — aborting');
|
||||
return;
|
||||
}
|
||||
|
||||
this._encryptionRequiredDialog = this._matDialog.open(
|
||||
DialogEnableEncryptionComponent,
|
||||
{
|
||||
disableClose: true,
|
||||
data: { providerType: 'supersync', initialSetup: true },
|
||||
},
|
||||
);
|
||||
|
||||
this._encryptionRequiredDialog.afterClosed().subscribe((result) => {
|
||||
this._encryptionRequiredDialog = undefined;
|
||||
if (result?.success) {
|
||||
this.sync();
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this._isOpeningEncryptionDialog = false;
|
||||
}
|
||||
} else {
|
||||
SyncLog.log(
|
||||
'Skipping encryption dialog — guard blocked: _encryptionRequiredDialog=',
|
||||
!!this._encryptionRequiredDialog,
|
||||
', _isOpeningEncryptionDialog=',
|
||||
this._isOpeningEncryptionDialog,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _openConflictDialog$(
|
||||
conflictData: ConflictData,
|
||||
|
|
@ -915,7 +1076,10 @@ export class SyncWrapperService {
|
|||
disableClose: true,
|
||||
data: conflictData,
|
||||
});
|
||||
return this.lastConflictDialog.afterClosed();
|
||||
// disableClose: true ensures the dialog always closes with a result
|
||||
return this.lastConflictDialog
|
||||
.afterClosed()
|
||||
.pipe(filter((r): r is DialogConflictResolutionResult => r !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1039,12 +1039,21 @@ const T = {
|
|||
},
|
||||
D_SYNC_IMPORT_CONFLICT: {
|
||||
TITLE: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.TITLE',
|
||||
MESSAGE: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.MESSAGE',
|
||||
EXPLANATION: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.EXPLANATION',
|
||||
MSG_INCOMING: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.MSG_INCOMING',
|
||||
MSG_INCOMING_NO_OPS: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.MSG_INCOMING_NO_OPS',
|
||||
MSG_LOCAL_FILTERS: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.MSG_LOCAL_FILTERS',
|
||||
IMPORT_DATE: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.IMPORT_DATE',
|
||||
FILTERED_CHANGES: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.FILTERED_CHANGES',
|
||||
USE_LOCAL: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.USE_LOCAL',
|
||||
USE_REMOTE: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.USE_REMOTE',
|
||||
RECOMMEND_SERVER: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.RECOMMEND_SERVER',
|
||||
REASON_PASSWORD_CHANGED: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.REASON_PASSWORD_CHANGED',
|
||||
REASON_FILE_IMPORT: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.REASON_FILE_IMPORT',
|
||||
REASON_BACKUP_RESTORE: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.REASON_BACKUP_RESTORE',
|
||||
REASON_FORCE_UPLOAD: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.REASON_FORCE_UPLOAD',
|
||||
REASON_SERVER_MIGRATION: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.REASON_SERVER_MIGRATION',
|
||||
REASON_REPAIR: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.REASON_REPAIR',
|
||||
REASON_UNKNOWN: 'F.SYNC.D_SYNC_IMPORT_CONFLICT.REASON_UNKNOWN',
|
||||
},
|
||||
D_LOCAL_DATA_REPLACE_CONFIRM: {
|
||||
MESSAGE: 'F.SYNC.D_LOCAL_DATA_REPLACE_CONFIRM.MESSAGE',
|
||||
|
|
@ -1053,11 +1062,12 @@ const T = {
|
|||
D_INCOMPLETE_SYNC: {
|
||||
BTN_CLOSE_APP: 'F.SYNC.D_INCOMPLETE_SYNC.BTN_CLOSE_APP',
|
||||
BTN_DOWNLOAD_BACKUP: 'F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP',
|
||||
BTN_FORCE_DOWNLOAD: 'F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_DOWNLOAD',
|
||||
BTN_FORCE_UPLOAD: 'F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD',
|
||||
INCOHERENT_P1: 'F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P1',
|
||||
INCOHERENT_P2: 'F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P2',
|
||||
P1: 'F.SYNC.D_INCOMPLETE_SYNC.P1',
|
||||
P2: 'F.SYNC.D_INCOMPLETE_SYNC.P2',
|
||||
T1: 'F.SYNC.D_INCOMPLETE_SYNC.T1',
|
||||
T2: 'F.SYNC.D_INCOMPLETE_SYNC.T2',
|
||||
T3: 'F.SYNC.D_INCOMPLETE_SYNC.T3',
|
||||
T4: 'F.SYNC.D_INCOMPLETE_SYNC.T4',
|
||||
T5: 'F.SYNC.D_INCOMPLETE_SYNC.T5',
|
||||
|
|
@ -1166,6 +1176,14 @@ const T = {
|
|||
'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_SUPERSYNC_ONLY',
|
||||
ENABLE_ENCRYPTION_PASSWORD_REQUIRED:
|
||||
'F.SYNC.FORM.SUPER_SYNC.ENABLE_ENCRYPTION_PASSWORD_REQUIRED',
|
||||
ENCRYPTION_ENCOURAGED: 'F.SYNC.FORM.SUPER_SYNC.ENCRYPTION_ENCOURAGED',
|
||||
ENCRYPTION_SETUP_INTRO: 'F.SYNC.FORM.SUPER_SYNC.ENCRYPTION_SETUP_INTRO',
|
||||
SETUP_ENCRYPTION_TITLE: 'F.SYNC.FORM.SUPER_SYNC.SETUP_ENCRYPTION_TITLE',
|
||||
SETUP_ENCRYPTION_PASSWORD_WARNING:
|
||||
'F.SYNC.FORM.SUPER_SYNC.SETUP_ENCRYPTION_PASSWORD_WARNING',
|
||||
SETUP_ENCRYPTION_BTN: 'F.SYNC.FORM.SUPER_SYNC.SETUP_ENCRYPTION_BTN',
|
||||
SETUP_DISABLE_SUPERSYNC_BTN:
|
||||
'F.SYNC.FORM.SUPER_SYNC.SETUP_DISABLE_SUPERSYNC_BTN',
|
||||
},
|
||||
IMPORT_ENCRYPTION_WARNING: {
|
||||
TITLE: 'F.SYNC.FORM.IMPORT_ENCRYPTION_WARNING.TITLE',
|
||||
|
|
@ -1283,6 +1301,7 @@ const T = {
|
|||
ENCRYPTION_PASSWORD_REQUIRED: 'F.SYNC.S.ENCRYPTION_PASSWORD_REQUIRED',
|
||||
ENCRYPTION_DISABLED_ON_OTHER_DEVICE:
|
||||
'F.SYNC.S.ENCRYPTION_DISABLED_ON_OTHER_DEVICE',
|
||||
ENCRYPTION_REQUIRED_FOR_SUPERSYNC: 'F.SYNC.S.ENCRYPTION_REQUIRED_FOR_SUPERSYNC',
|
||||
WEB_CRYPTO_NOT_AVAILABLE: 'F.SYNC.S.WEB_CRYPTO_NOT_AVAILABLE',
|
||||
PERSIST_FAILED: 'F.SYNC.S.PERSIST_FAILED',
|
||||
DEFERRED_ACTION_FAILED: 'F.SYNC.S.DEFERRED_ACTION_FAILED',
|
||||
|
|
@ -1313,6 +1332,10 @@ const T = {
|
|||
SERVER_MIGRATION_VALIDATION_FAILED: 'F.SYNC.S.SERVER_MIGRATION_VALIDATION_FAILED',
|
||||
MIGRATION_FAILED: 'F.SYNC.S.MIGRATION_FAILED',
|
||||
VECTOR_CLOCK_LIMIT_REACHED: 'F.SYNC.S.VECTOR_CLOCK_LIMIT_REACHED',
|
||||
ENABLE_ENCRYPTION_FAILED: 'F.SYNC.S.ENABLE_ENCRYPTION_FAILED',
|
||||
DISABLE_ENCRYPTION_FAILED: 'F.SYNC.S.DISABLE_ENCRYPTION_FAILED',
|
||||
CHANGE_PASSWORD_FAILED: 'F.SYNC.S.CHANGE_PASSWORD_FAILED',
|
||||
OVERWRITE_SERVER_FAILED: 'F.SYNC.S.OVERWRITE_SERVER_FAILED',
|
||||
},
|
||||
D_DATA_REPAIRED: {
|
||||
TITLE: 'F.SYNC.D_DATA_REPAIRED.TITLE',
|
||||
|
|
@ -1880,11 +1903,16 @@ const T = {
|
|||
NONE: 'G.NONE',
|
||||
OK: 'G.OK',
|
||||
OVERDUE: 'G.OVERDUE',
|
||||
PASSWORD_STRENGTH_FAIR: 'G.PASSWORD_STRENGTH_FAIR',
|
||||
PROCESSING: 'G.PROCESSING',
|
||||
PASSWORD_STRENGTH_STRONG: 'G.PASSWORD_STRENGTH_STRONG',
|
||||
PASSWORD_STRENGTH_WEAK: 'G.PASSWORD_STRENGTH_WEAK',
|
||||
REMOVE: 'G.REMOVE',
|
||||
SAVE: 'G.SAVE',
|
||||
SHARE: 'G.SHARE',
|
||||
SUBMIT: 'G.SUBMIT',
|
||||
TITLE: 'G.TITLE',
|
||||
TOGGLE_PASSWORD_VISIBILITY: 'G.TOGGLE_PASSWORD_VISIBILITY',
|
||||
TODAY_TAG_TITLE: 'G.TODAY_TAG_TITLE',
|
||||
UNDO: 'G.UNDO',
|
||||
WITHOUT_PROJECT: 'G.WITHOUT_PROJECT',
|
||||
|
|
|
|||
134
src/app/ui/password-strength/password-strength.component.ts
Normal file
134
src/app/ui/password-strength/password-strength.component.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { T } from '../../t.const';
|
||||
|
||||
export type PasswordStrengthLevel = 'weak' | 'fair' | 'strong';
|
||||
|
||||
@Component({
|
||||
selector: 'password-strength',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (password()) {
|
||||
<div class="strength-bar">
|
||||
<div
|
||||
class="strength-fill"
|
||||
[class.weak]="level() === 'weak'"
|
||||
[class.fair]="level() === 'fair'"
|
||||
[class.strong]="level() === 'strong'"
|
||||
[style.width.%]="fillPercent()"
|
||||
></div>
|
||||
</div>
|
||||
<span
|
||||
class="strength-label"
|
||||
[class.weak]="level() === 'weak'"
|
||||
[class.fair]="level() === 'fair'"
|
||||
[class.strong]="level() === 'strong'"
|
||||
>{{ label() }}</span
|
||||
>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: -8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(128, 128, 128, 0.2);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.strength-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
&.weak {
|
||||
background: var(--c-warn);
|
||||
}
|
||||
|
||||
&.fair {
|
||||
background: var(--c-accent);
|
||||
}
|
||||
|
||||
&.strong {
|
||||
background: var(--palette-primary-500);
|
||||
}
|
||||
}
|
||||
|
||||
.strength-label {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
&.weak {
|
||||
color: var(--c-warn);
|
||||
}
|
||||
|
||||
&.fair {
|
||||
color: var(--c-accent);
|
||||
}
|
||||
|
||||
&.strong {
|
||||
color: var(--palette-primary-500);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PasswordStrengthComponent {
|
||||
private _translateService = inject(TranslateService);
|
||||
|
||||
password = input<string>('');
|
||||
minLength = input<number>(8);
|
||||
|
||||
level = computed<PasswordStrengthLevel>(() => {
|
||||
const pwd = this.password();
|
||||
if (!pwd || pwd.length < this.minLength()) {
|
||||
return 'weak';
|
||||
}
|
||||
let score = 0;
|
||||
if (pwd.length >= 12) score++;
|
||||
if (pwd.length >= 16) score++;
|
||||
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++;
|
||||
if (/\d/.test(pwd)) score++;
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) score++;
|
||||
if (score >= 4) return 'strong';
|
||||
if (score >= 2) return 'fair';
|
||||
return 'weak';
|
||||
});
|
||||
|
||||
fillPercent = computed(() => {
|
||||
switch (this.level()) {
|
||||
case 'weak':
|
||||
return 33;
|
||||
case 'fair':
|
||||
return 66;
|
||||
case 'strong':
|
||||
return 100;
|
||||
}
|
||||
});
|
||||
|
||||
label = computed(() => {
|
||||
switch (this.level()) {
|
||||
case 'weak':
|
||||
return this._translateService.instant(T.G.PASSWORD_STRENGTH_WEAK);
|
||||
case 'fair':
|
||||
return this._translateService.instant(T.G.PASSWORD_STRENGTH_FAIR);
|
||||
case 'strong':
|
||||
return this._translateService.instant(T.G.PASSWORD_STRENGTH_STRONG);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1009,24 +1009,33 @@
|
|||
"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",
|
||||
"FORCE_OVERWRITE_INFO": "If you don’t know the old password, you can overwrite the server with your local data and a new password.",
|
||||
"FORCE_OVERWRITE_INFO": "If you prefer to keep your local data, you can overwrite the server instead.",
|
||||
"FORCE_OVERWRITE_TITLE": "Overwrite Server Data?",
|
||||
"FORCE_OVERWRITE_CONFIRM": "This will <strong>delete all data on the server</strong> and upload your local data with the new password. Unsynced local changes may be lost. Continue?",
|
||||
"BTN_FORCE_OVERWRITE": "Force Overwrite Server"
|
||||
"FORCE_OVERWRITE_CONFIRM": "This will <strong>delete all data on the server</strong> and upload your local data encrypted with this password. Continue?",
|
||||
"BTN_FORCE_OVERWRITE": "Use Local Data"
|
||||
},
|
||||
"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?",
|
||||
"MESSAGE": "This appears to be a fresh installation. Remote data with {{count}} changes was found. Do you want to download and overwrite your local data with it?",
|
||||
"OK": "Download Remote Data",
|
||||
"TITLE": "Initial Sync"
|
||||
},
|
||||
"D_SYNC_IMPORT_CONFLICT": {
|
||||
"TITLE": "Sync Conflict Detected",
|
||||
"MESSAGE": "Your local data was imported/restored, but the server has {{filteredOpCount}} changes from other devices.",
|
||||
"EXPLANATION": "These changes were made without knowledge of your import and cannot be automatically merged.",
|
||||
"MSG_INCOMING": "Your data was fully replaced on another device. You have {{filteredOpCount}} local changes that may not carry over.",
|
||||
"MSG_INCOMING_NO_OPS": "Your data was fully replaced on another device. This device will sync to the new state.",
|
||||
"MSG_LOCAL_FILTERS": "Your local data was imported/restored, but the server has {{filteredOpCount}} changes from other devices that cannot be automatically merged.",
|
||||
"IMPORT_DATE": "Import date",
|
||||
"FILTERED_CHANGES": "Filtered changes",
|
||||
"FILTERED_CHANGES": "Affected changes",
|
||||
"USE_LOCAL": "Use My Data",
|
||||
"USE_REMOTE": "Use Server Data"
|
||||
"USE_REMOTE": "Use Server Data",
|
||||
"RECOMMEND_SERVER": "Recommended: Accept the server data to stay in sync with the other device.",
|
||||
"REASON_PASSWORD_CHANGED": "Reason: The encryption password was changed on another device.",
|
||||
"REASON_FILE_IMPORT": "Reason: A data file was imported on another device.",
|
||||
"REASON_BACKUP_RESTORE": "Reason: A backup was restored on another device.",
|
||||
"REASON_FORCE_UPLOAD": "Reason: Another device force-uploaded its local data.",
|
||||
"REASON_SERVER_MIGRATION": "Reason: Data was migrated to a new sync server.",
|
||||
"REASON_REPAIR": "Reason: An automatic data repair was performed.",
|
||||
"REASON_UNKNOWN": "Reason: A full state replacement was performed."
|
||||
},
|
||||
"D_LOCAL_DATA_REPLACE_CONFIRM": {
|
||||
"MESSAGE": "You have {{count}} unsynced local changes that will be replaced by remote data. Do you want to continue?",
|
||||
|
|
@ -1035,11 +1044,12 @@
|
|||
"D_INCOMPLETE_SYNC": {
|
||||
"BTN_CLOSE_APP": "Close App",
|
||||
"BTN_DOWNLOAD_BACKUP": "Download Local Backup",
|
||||
"BTN_FORCE_DOWNLOAD": "Force Download Remote",
|
||||
"BTN_FORCE_UPLOAD": "Force Upload Local",
|
||||
"INCOHERENT_P1": "The date of your remote data is from the future",
|
||||
"INCOHERENT_P2": "You should check the time configured on your systems!",
|
||||
"P1": "The remote sync data is incoherent!",
|
||||
"P2": "Affected model:",
|
||||
"T1": "Last sync was incomplete!",
|
||||
"T2": "Your archive data was not properly uploaded during last sync:",
|
||||
"T3": "You have 2 Options:",
|
||||
"T4": "1. Go to your other device and complete the sync there.",
|
||||
"T5": "2. Or you can overwrite the remote data with your local one. All remote changes will\n be lost!",
|
||||
|
|
@ -1137,7 +1147,13 @@
|
|||
"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."
|
||||
"ENABLE_ENCRYPTION_PASSWORD_REQUIRED": "Please enter an encryption password first.",
|
||||
"ENCRYPTION_ENCOURAGED": "Encryption is required for SuperSync. Please enable encryption to protect your data.",
|
||||
"ENCRYPTION_SETUP_INTRO": "<strong>SuperSync password-encrypts your data before it leaves your device</strong>, making misuse or leaks of your private data almost impossible.",
|
||||
"SETUP_ENCRYPTION_TITLE": "SuperSync: Set Password",
|
||||
"SETUP_ENCRYPTION_PASSWORD_WARNING": "Use the same password on all devices. A forgotten password can be reset by overwriting remote data.",
|
||||
"SETUP_ENCRYPTION_BTN": "Set Password",
|
||||
"SETUP_DISABLE_SUPERSYNC_BTN": "Disable SuperSync"
|
||||
},
|
||||
"IMPORT_ENCRYPTION_WARNING": {
|
||||
"TITLE": "Encryption Settings Will Change",
|
||||
|
|
@ -1245,6 +1261,7 @@
|
|||
"DECRYPTION_FAILED": "Failed to decrypt synced data. Please check your encryption password.",
|
||||
"ENCRYPTION_PASSWORD_REQUIRED": "Encrypted data received but no encryption password is configured. Please set your encryption password in sync settings.",
|
||||
"ENCRYPTION_DISABLED_ON_OTHER_DEVICE": "Encryption was disabled on another device. Your sync settings have been updated.",
|
||||
"ENCRYPTION_REQUIRED_FOR_SUPERSYNC": "Encryption is required for SuperSync. Please set an encryption password to continue syncing.",
|
||||
"WEB_CRYPTO_NOT_AVAILABLE": "Encryption is not available on this device. On Android, encryption requires a secure context (HTTPS) which is not available. Please disable encryption in sync settings or sync from a desktop web browser.",
|
||||
"PERSIST_FAILED": "Failed to save changes. State may be inconsistent - please reload.",
|
||||
"DEFERRED_ACTION_FAILED": "Some changes made during sync could not be saved. Please reload to recover.",
|
||||
|
|
@ -1274,7 +1291,11 @@
|
|||
"FINISH_DAY_SYNC_ERROR": "Sync error while finishing day. Please try again.",
|
||||
"SERVER_MIGRATION_VALIDATION_FAILED": "Could not migrate data to server - local data validation failed. Please try importing a backup.",
|
||||
"MIGRATION_FAILED": "Some sync data could not be migrated and was skipped. This may indicate data corruption or a bug.",
|
||||
"VECTOR_CLOCK_LIMIT_REACHED": "Sync has tracked {{originalSize}} devices/browsers, exceeding the limit of {{maxSize}}. Inactive device entries were pruned. Consider a sync reset if you experience issues."
|
||||
"VECTOR_CLOCK_LIMIT_REACHED": "Sync has tracked {{originalSize}} devices/browsers, exceeding the limit of {{maxSize}}. Inactive device entries were pruned. Consider a sync reset if you experience issues.",
|
||||
"ENABLE_ENCRYPTION_FAILED": "Failed to enable encryption: {{message}}",
|
||||
"DISABLE_ENCRYPTION_FAILED": "Failed to disable encryption: {{message}}",
|
||||
"CHANGE_PASSWORD_FAILED": "Failed to change password: {{message}}",
|
||||
"OVERWRITE_SERVER_FAILED": "Failed to overwrite server data: {{message}}"
|
||||
},
|
||||
"D_DATA_REPAIRED": {
|
||||
"TITLE": "Data Repair Executed",
|
||||
|
|
@ -1833,11 +1854,16 @@
|
|||
"NONE": "None",
|
||||
"OK": "Ok",
|
||||
"OVERDUE": "Overdue",
|
||||
"PASSWORD_STRENGTH_FAIR": "Fair",
|
||||
"PASSWORD_STRENGTH_STRONG": "Strong",
|
||||
"PASSWORD_STRENGTH_WEAK": "Weak",
|
||||
"PROCESSING": "Processing",
|
||||
"REMOVE": "Remove",
|
||||
"SAVE": "Save",
|
||||
"SHARE": "Share",
|
||||
"SUBMIT": "Submit",
|
||||
"TITLE": "Title",
|
||||
"TOGGLE_PASSWORD_VISIBILITY": "Toggle password visibility",
|
||||
"TODAY_TAG_TITLE": "Today",
|
||||
"UNDO": "Undo",
|
||||
"WITHOUT_PROJECT": "Without Project",
|
||||
|
|
|
|||
19
src/main.ts
19
src/main.ts
|
|
@ -48,7 +48,8 @@ import { StoreModule } from '@ngrx/store';
|
|||
import { META_REDUCERS } from './app/root-store/meta/meta-reducer-registry';
|
||||
import { setOperationCaptureService } from './app/root-store/meta/task-shared-meta-reducers';
|
||||
import { OperationCaptureService } from './app/op-log/capture/operation-capture.service';
|
||||
import { EncryptionPasswordDialogOpenerInitService } from './app/imex/sync/encryption-password-dialog-opener-init.service';
|
||||
import { EncryptionPasswordDialogOpenerService } from './app/imex/sync/encryption-password-dialog-opener.service';
|
||||
import { DataInitService } from './app/core/data-init/data-init.service';
|
||||
import { EffectsModule } from '@ngrx/effects';
|
||||
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
|
|
@ -220,14 +221,24 @@ bootstrapApplication(AppComponent, {
|
|||
deps: [OperationCaptureService],
|
||||
multi: true,
|
||||
},
|
||||
// Ensure DataInitService is instantiated at bootstrap.
|
||||
// Its constructor triggers reInit() → hydrateStore() → loadAllData into NgRx.
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: (_dataInit: DataInitService) => {
|
||||
return () => {};
|
||||
},
|
||||
deps: [DataInitService],
|
||||
multi: true,
|
||||
},
|
||||
// Initialize encryption password dialog opener for static form config functions
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
useFactory: (_initService: EncryptionPasswordDialogOpenerInitService) => {
|
||||
// Service constructor initializes the module-level reference
|
||||
useFactory: (_opener: EncryptionPasswordDialogOpenerService) => {
|
||||
// Service constructor self-registers the module-level reference
|
||||
return () => {};
|
||||
},
|
||||
deps: [EncryptionPasswordDialogOpenerInitService],
|
||||
deps: [EncryptionPasswordDialogOpenerService],
|
||||
multi: true,
|
||||
},
|
||||
// Note: ImmediateUploadService now initializes itself in constructor
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue