diff --git a/src/app/features/config/form-cfgs/sync-form.const.ts b/src/app/features/config/form-cfgs/sync-form.const.ts index 850e12d67b..29901a8c25 100644 --- a/src/app/features/config/form-cfgs/sync-form.const.ts +++ b/src/app/features/config/form-cfgs/sync-form.const.ts @@ -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 = { }, }, }, + // 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 = { 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 = { 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 = { 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', diff --git a/src/app/features/config/store/global-config.reducer.ts b/src/app/features/config/store/global-config.reducer.ts index edc696f4bd..d7e39f4d5c 100644 --- a/src/app/features/config/store/global-config.reducer.ts +++ b/src/app/features/config/store/global-config.reducer.ts @@ -118,6 +118,7 @@ export const globalConfigReducer = createReducer( // 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( ? 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( ...incomingSyncConfig, syncProvider, isEnabled, + isEncryptionEnabled, }, }; }), diff --git a/src/app/imex/sync/_dialog-encryption-common.scss b/src/app/imex/sync/_dialog-encryption-common.scss index a2d24c4c30..303ed3ca18 100644 --- a/src/app/imex/sync/_dialog-encryption-common.scss +++ b/src/app/imex/sync/_dialog-encryption-common.scss @@ -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 *; diff --git a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.html b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.html index 7740cf0ae6..6f7f6ead57 100644 --- a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.html +++ b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.html @@ -47,33 +47,35 @@ - + @if (providerType !== 'supersync') { + -
-

{{ textKeys.L_REMOVE_ENCRYPTION | translate }}

-

- {{ textKeys.REMOVE_ENCRYPTION_WARNING | translate }} -

- -
+
+

{{ textKeys.L_REMOVE_ENCRYPTION | translate }}

+

+ {{ textKeys.REMOVE_ENCRYPTION_WARNING | translate }} +

+ +
+ } } @else {
diff --git a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.ts b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.ts index 96c8f5ba2e..4116c813ad 100644 --- a/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.ts +++ b/src/app/imex/sync/dialog-change-encryption-password/dialog-change-encryption-password.component.ts @@ -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); } diff --git a/src/app/imex/sync/dialog-disable-encryption/dialog-disable-encryption.component.html b/src/app/imex/sync/dialog-disable-encryption/dialog-disable-encryption.component.html deleted file mode 100644 index fb5a48e798..0000000000 --- a/src/app/imex/sync/dialog-disable-encryption/dialog-disable-encryption.component.html +++ /dev/null @@ -1,76 +0,0 @@ -

- {{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_TITLE | translate }} -

- -@if (!canProceed()) { - -
- -
-

{{ errorReason() | translate }}

-

{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ENABLE_FIRST | translate }}

-
-
-
- - - - -} @else { - -
- -

{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_WARNING | translate }}

-
- -
-

{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_WHAT_HAPPENS | translate }}

-
    -
  • {{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ITEM_1 | translate }}
  • -
  • {{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ITEM_2 | translate }}
  • -
  • {{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ITEM_3 | translate }}
  • -
-
- -

- - {{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_NOTE | translate }} -

-
- - - - - -} diff --git a/src/app/imex/sync/dialog-disable-encryption/dialog-disable-encryption.component.scss b/src/app/imex/sync/dialog-disable-encryption/dialog-disable-encryption.component.scss deleted file mode 100644 index e5b68cc7e5..0000000000 --- a/src/app/imex/sync/dialog-disable-encryption/dialog-disable-encryption.component.scss +++ /dev/null @@ -1,3 +0,0 @@ -@use '../dialog-encryption-common' as *; - -@include dialog-encryption-common-classes; diff --git a/src/app/imex/sync/dialog-disable-encryption/dialog-disable-encryption.component.ts b/src/app/imex/sync/dialog-disable-encryption/dialog-disable-encryption.component.ts deleted file mode 100644 index 2374839642..0000000000 --- a/src/app/imex/sync/dialog-disable-encryption/dialog-disable-encryption.component.ts +++ /dev/null @@ -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, - ); - - T: typeof T = T; - isLoading = signal(false); - canProceed = signal(true); - errorReason = signal(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 { - 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 }); - } -} diff --git a/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.html b/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.html index bc4408dca9..ffb7f27ad9 100644 --- a/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.html +++ b/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.html @@ -1,5 +1,9 @@

- {{ textKeys.ENABLE_ENCRYPTION_TITLE | translate }} + @if (initialSetup) { + {{ textKeys.SETUP_ENCRYPTION_TITLE | translate }} + } @else { + {{ textKeys.ENABLE_ENCRYPTION_TITLE | translate }} + }

@if (!canProceed()) { @@ -21,7 +25,91 @@ {{ T.G.CLOSE | translate }} +} @else if (initialSetup) { + + +
+ +
+

+

{{ textKeys.SETUP_ENCRYPTION_PASSWORD_WARNING | translate }}

+
+
+ +
+ + {{ T.F.SYNC.FORM.L_ENCRYPTION_PASSWORD | translate }} + + + + + + + + {{ textKeys.L_CONFIRM_PASSWORD | translate }} + + + + @if (passwordError) { +
+ + {{ passwordError | translate }} +
+ } +
+
+ + + + + } @else { +
@@ -33,18 +121,33 @@ {{ T.F.SYNC.FORM.L_ENCRYPTION_PASSWORD | translate }} + + + {{ textKeys.L_CONFIRM_PASSWORD | translate }} @@ -91,13 +194,11 @@ @if (isLoading()) { } @else { - - - {{ textKeys.BTN_ENABLE_ENCRYPTION | translate }} - + + {{ textKeys.BTN_ENABLE_ENCRYPTION | translate }} } diff --git a/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.scss b/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.scss index e5b68cc7e5..4434fdaec1 100644 --- a/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.scss +++ b/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.scss @@ -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; +} diff --git a/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.ts b/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.ts index c4e9426ee1..2fa232f508 100644 --- a/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.ts +++ b/src/app/imex/sync/dialog-enable-encryption/dialog-enable-encryption.component.ts @@ -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(MAT_DIALOG_DATA, { optional: true, }); @@ -66,6 +76,8 @@ export class DialogEnableEncryptionComponent { isLoading = signal(false); canProceed = signal(true); errorReason = signal(null); + showPassword = signal(false); + initialSetup: boolean = this._data?.initialSetup || false; providerType: 'supersync' | 'file-based' = this._data?.providerType || 'supersync'; textKeys: Record = 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 }); } diff --git a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.html b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.html index 6811eb7a88..9f194145be 100644 --- a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.html +++ b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.html @@ -22,6 +22,12 @@ /> + + @if (isSuperSync()) { +

+ {{ T.F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_INFO | translate }} +

+ }
@@ -32,10 +38,22 @@ [disabled]="isLoading()" (click)="cancel()" > - close {{ T.G.CANCEL | translate }} + @if (isSuperSync()) { + + } + diff --git a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.scss b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.scss index b6641566a4..d6946068a9 100644 --- a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.scss +++ b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.scss @@ -6,7 +6,7 @@ margin-bottom: 16px; } - .force-overwrite-note { + .force-overwrite-info { margin-top: 12px; font-size: 13px; } diff --git a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.ts b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.ts index 6eb8b41583..c2450cc1ef 100644 --- a/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.ts +++ b/src/app/imex/sync/dialog-enter-encryption-password/dialog-enter-encryption-password.component.ts @@ -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); diff --git a/src/app/imex/sync/dialog-handle-decrypt-error/dialog-handle-decrypt-error.component.ts b/src/app/imex/sync/dialog-handle-decrypt-error/dialog-handle-decrypt-error.component.ts index d8675d7f43..5e13b39e1a 100644 --- a/src/app/imex/sync/dialog-handle-decrypt-error/dialog-handle-decrypt-error.component.ts +++ b/src/app/imex/sync/dialog-handle-decrypt-error/dialog-handle-decrypt-error.component.ts @@ -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); @@ -42,16 +45,35 @@ export class DialogHandleDecryptErrorComponent { passwordVal: string = ''; async updatePWAndForceUpload(): Promise { - 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 { - 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({}); } } diff --git a/src/app/imex/sync/dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component.html b/src/app/imex/sync/dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component.html deleted file mode 100644 index 8a787a53b6..0000000000 --- a/src/app/imex/sync/dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component.html +++ /dev/null @@ -1,55 +0,0 @@ - -
- error - -
- -

The date of your remote data are from the future

-

You should check the time configured on your systems!

- -
- -
-
- - -
- - - - - -
-
diff --git a/src/app/imex/sync/dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component.ts b/src/app/imex/sync/dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component.ts deleted file mode 100644 index 09f8d9dc9c..0000000000 --- a/src/app/imex/sync/dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component.ts +++ /dev/null @@ -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); - private _backupService = inject(BackupService); - - data = inject(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 { - 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(); - } - } -} diff --git a/src/app/imex/sync/dialog-incomplete-sync-old/dialog-incomplete-sync-old.component.html b/src/app/imex/sync/dialog-incomplete-sync-old/dialog-incomplete-sync-old.component.html deleted file mode 100644 index c2a7b79c12..0000000000 --- a/src/app/imex/sync/dialog-incomplete-sync-old/dialog-incomplete-sync-old.component.html +++ /dev/null @@ -1,71 +0,0 @@ - -
- error - -
- -

- {{ T.F.SYNC.D_INCOMPLETE_SYNC.T1 | translate }} -

- -

- {{ T.F.SYNC.D_INCOMPLETE_SYNC.T2 | translate }} - mfRev: {{ data?.archiveRevInMainFile }} & realRev: {{ data?.archiveRevReal }} -

-

{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}

- -

{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}

-

{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}

- -

- {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} -

- -
- -
-
- - -
- - @if (!IS_ANDROID_WEB_VIEW) { - - } - - -
-
diff --git a/src/app/imex/sync/dialog-incomplete-sync-old/dialog-incomplete-sync-old.component.scss b/src/app/imex/sync/dialog-incomplete-sync-old/dialog-incomplete-sync-old.component.scss deleted file mode 100644 index 531f19f41e..0000000000 --- a/src/app/imex/sync/dialog-incomplete-sync-old/dialog-incomplete-sync-old.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -:host > * { - max-width: 500px; -} - -:host code { - font-size: 10px; -} diff --git a/src/app/imex/sync/dialog-incomplete-sync-old/dialog-incomplete-sync-old.component.ts b/src/app/imex/sync/dialog-incomplete-sync-old/dialog-incomplete-sync-old.component.ts deleted file mode 100644 index 1309f23bbc..0000000000 --- a/src/app/imex/sync/dialog-incomplete-sync-old/dialog-incomplete-sync-old.component.ts +++ /dev/null @@ -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); - private _backupService = inject(BackupService); - data? = inject(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 { - 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(); - } - } -} diff --git a/src/app/imex/sync/dialog-incomplete-sync/dialog-incomplete-sync.component.html b/src/app/imex/sync/dialog-incomplete-sync/dialog-incomplete-sync.component.html deleted file mode 100644 index e015d0032d..0000000000 --- a/src/app/imex/sync/dialog-incomplete-sync/dialog-incomplete-sync.component.html +++ /dev/null @@ -1,70 +0,0 @@ - -
- error - -
- -

- {{ T.F.SYNC.D_INCOMPLETE_SYNC.P1 | translate }} -

- -

- {{ T.F.SYNC.D_INCOMPLETE_SYNC.P2 | translate }} - {{ data?.modelId }} -

- -

{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}

- -

{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}

-

{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}

- -

- {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} -

- -
- -
-
- - -
- - @if (!IS_ANDROID_WEB_VIEW) { - - } - - -
-
diff --git a/src/app/imex/sync/dialog-incomplete-sync/dialog-incomplete-sync.component.scss b/src/app/imex/sync/dialog-incomplete-sync/dialog-incomplete-sync.component.scss deleted file mode 100644 index 531f19f41e..0000000000 --- a/src/app/imex/sync/dialog-incomplete-sync/dialog-incomplete-sync.component.scss +++ /dev/null @@ -1,7 +0,0 @@ -:host > * { - max-width: 500px; -} - -:host code { - font-size: 10px; -} diff --git a/src/app/imex/sync/dialog-sync-error/dialog-sync-error.component.html b/src/app/imex/sync/dialog-sync-error/dialog-sync-error.component.html new file mode 100644 index 0000000000..13f5409434 --- /dev/null +++ b/src/app/imex/sync/dialog-sync-error/dialog-sync-error.component.html @@ -0,0 +1,90 @@ + +
+ +
+ + @if (data.type === 'incomplete-sync') { +

+ {{ T.F.SYNC.D_INCOMPLETE_SYNC.P1 | translate }} +

+ +

+ {{ T.F.SYNC.D_INCOMPLETE_SYNC.P2 | translate }} + {{ data.modelId }} +

+ +

{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}

+ +

{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}

+

{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}

+ +

+ {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} +

+ } @else { +

+ {{ T.F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P1 | translate }} +

+

{{ T.F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P2 | translate }}

+ } + +
+ +
+
+ + +
+ + + @if (data.type === 'incomplete-sync' && !IS_ANDROID_WEB_VIEW) { + + } + + @if (data.type === 'incoherent-timestamps') { + + } + + +
+
diff --git a/src/app/imex/sync/dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component.scss b/src/app/imex/sync/dialog-sync-error/dialog-sync-error.component.scss similarity index 100% rename from src/app/imex/sync/dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component.scss rename to src/app/imex/sync/dialog-sync-error/dialog-sync-error.component.scss diff --git a/src/app/imex/sync/dialog-incomplete-sync/dialog-incomplete-sync.component.ts b/src/app/imex/sync/dialog-sync-error/dialog-sync-error.component.ts similarity index 62% rename from src/app/imex/sync/dialog-incomplete-sync/dialog-incomplete-sync.component.ts rename to src/app/imex/sync/dialog-sync-error/dialog-sync-error.component.ts index 88cd0624be..b52a796301 100644 --- a/src/app/imex/sync/dialog-incomplete-sync/dialog-incomplete-sync.component.ts +++ b/src/app/imex/sync/dialog-sync-error/dialog-sync-error.component.ts @@ -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); +export class DialogSyncErrorComponent { + private _matDialogRef = inject>(MatDialogRef); private _backupService = inject(BackupService); - data = inject(MAT_DIALOG_DATA); + data = inject(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 { @@ -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); } diff --git a/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts b/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts index 5d61d75fb5..741c0bf182 100644 --- a/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts +++ b/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts @@ -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); diff --git a/src/app/imex/sync/encryption-disable.service.ts b/src/app/imex/sync/encryption-disable.service.ts deleted file mode 100644 index 20497f9be8..0000000000 --- a/src/app/imex/sync/encryption-disable.service.ts +++ /dev/null @@ -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 { - 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 { - 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}`, - ); - } - } -} diff --git a/src/app/imex/sync/encryption-enable.service.ts b/src/app/imex/sync/encryption-enable.service.ts deleted file mode 100644 index d287bb17f7..0000000000 --- a/src/app/imex/sync/encryption-enable.service.ts +++ /dev/null @@ -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 { - 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}`, - ); - } - } -} diff --git a/src/app/imex/sync/encryption-password-change.service.ts b/src/app/imex/sync/encryption-password-change.service.ts index 26e7e69f11..bb75cfa88e 100644 --- a/src/app/imex/sync/encryption-password-change.service.ts +++ b/src/app/imex/sync/encryption-password-change.service.ts @@ -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, diff --git a/src/app/imex/sync/encryption-password-dialog-opener-init.service.ts b/src/app/imex/sync/encryption-password-dialog-opener-init.service.ts deleted file mode 100644 index f5f967dcfa..0000000000 --- a/src/app/imex/sync/encryption-password-dialog-opener-init.service.ts +++ /dev/null @@ -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); - } -} diff --git a/src/app/imex/sync/encryption-password-dialog-opener.service.ts b/src/app/imex/sync/encryption-password-dialog-opener.service.ts index 3edeff62cb..0d7b43e6ae 100644 --- a/src/app/imex/sync/encryption-password-dialog-opener.service.ts +++ b/src/app/imex/sync/encryption-password-dialog-opener.service.ts @@ -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 = (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 { - 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 { + ): Promise { 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 { - 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()); }; diff --git a/src/app/imex/sync/file-based-encryption.service.ts b/src/app/imex/sync/file-based-encryption.service.ts index c807c0204e..597b1c0364 100644 --- a/src/app/imex/sync/file-based-encryption.service.ts +++ b/src/app/imex/sync/file-based-encryption.service.ts @@ -38,13 +38,19 @@ export class FileBasedEncryptionService { await this._applyEncryption(newPassword, 'change'); } + async disableEncryption(): Promise { + await this._applyEncryption(undefined, 'disable'); + } + private async _applyEncryption( - encryptKey: string, - action: 'enable' | 'change', + encryptKey: string | undefined, + action: 'enable' | 'change' | 'disable', ): Promise { 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(); diff --git a/src/app/imex/sync/import-encryption-handler.service.ts b/src/app/imex/sync/import-encryption-handler.service.ts index ca9ac35a1f..7e443b6581 100644 --- a/src/app/imex/sync/import-encryption-handler.service.ts +++ b/src/app/imex/sync/import-encryption-handler.service.ts @@ -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', diff --git a/src/app/imex/sync/legacy-sync.model.ts b/src/app/imex/sync/legacy-sync.model.ts deleted file mode 100644 index 485b99c365..0000000000 --- a/src/app/imex/sync/legacy-sync.model.ts +++ /dev/null @@ -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; -} diff --git a/src/app/imex/sync/snapshot-upload.service.ts b/src/app/imex/sync/snapshot-upload.service.ts index a8d2eaa7d1..054eafb704 100644 --- a/src/app/imex/sync/snapshot-upload.service.ts +++ b/src/app/imex/sync/snapshot-upload.service.ts @@ -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({ diff --git a/src/app/imex/sync/supersync-encryption-toggle.service.ts b/src/app/imex/sync/supersync-encryption-toggle.service.ts new file mode 100644 index 0000000000..b7cce6ce2d --- /dev/null +++ b/src/app/imex/sync/supersync-encryption-toggle.service.ts @@ -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 { + 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 { + 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[0], + payload: unknown, + clientId: string, + vectorClock: Record, + isPayloadEncrypted: boolean, + ): Promise { + 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, + ); + } +} diff --git a/src/app/imex/sync/sync-config.service.ts b/src/app/imex/sync/sync-config.service.ts index 1b233a0e37..a89b513ee6 100644 --- a/src/app/imex/sync/sync-config.service.ts +++ b/src/app/imex/sync/sync-config.service.ts @@ -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 { @@ -331,7 +340,7 @@ export class SyncConfigService { {} as Record, ); - // 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 = diff --git a/src/app/imex/sync/sync-wrapper.service.ts b/src/app/imex/sync/sync-wrapper.service.ts index 573f1ccf9a..a0456e955e 100644 --- a/src/app/imex/sync/sync-wrapper.service.ts +++ b/src/app/imex/sync/sync-wrapper.service.ts @@ -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 { + 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, - ): Promise { - 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; + 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 because dialog components are dynamically imported. */ - private _passwordDialog?: MatDialogRef; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _passwordDialog?: MatDialogRef>; + + /** + * Reference to the encryption-required dialog to prevent multiple opens. + * Uses Record because dialog component is dynamically imported. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _encryptionRequiredDialog?: MatDialogRef>; + + /** + * 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 { + 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)); } /** diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 7baa424ba1..1148a0de8f 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -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', diff --git a/src/app/ui/password-strength/password-strength.component.ts b/src/app/ui/password-strength/password-strength.component.ts new file mode 100644 index 0000000000..0300784fe2 --- /dev/null +++ b/src/app/ui/password-strength/password-strength.component.ts @@ -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()) { +
+
+
+ {{ label() }} + } + `, + 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(''); + minLength = input(8); + + level = computed(() => { + 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); + } + }); +} diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 4339e52604..2a9357009b 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -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 delete all data on the server 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 delete all data on the server 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": "SuperSync password-encrypts your data before it leaves your device, 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", diff --git a/src/main.ts b/src/main.ts index 17bf8849c7..2cba12fe1c 100644 --- a/src/main.ts +++ b/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