feat(sync): add mandatory encryption for SuperSync

Implement end-to-end encryption for SuperSync with the following:

- Make encryption mandatory for SuperSync provider (optional for others)
- Add encryption setup during initial SuperSync configuration
- Add password strength indicator and toggle visibility in dialogs
- Probe server for encrypted data before showing encryption dialog
- Handle encryption state preservation during file import
- Prevent duplicate password dialogs and cascade between clients
- Simplify encryption codebase by removing dead code and deduplicating
- Add password-strength UI component
- Default sync provider to SuperSync in initial setup dialog
- Remove legacy dialog components (incomplete-sync, incoherent-timestamps)
This commit is contained in:
Johannes Millan 2026-03-01 12:50:23 +01:00
parent 90900e3aa1
commit 3ef23354e4
42 changed files with 1227 additions and 1386 deletions

View file

@ -10,7 +10,6 @@ import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform';
import { SUPER_SYNC_DEFAULT_BASE_URL } from '../../../op-log/sync-providers/super-sync/super-sync.model';
import {
closeAllDialogs,
openDisableEncryptionDialog,
openDisableEncryptionDialogForFileBased,
openEnableEncryptionDialog,
openEnableEncryptionDialogForFileBased,
@ -308,7 +307,10 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
},
},
},
// Hide disable encryption for SuperSync — encryption is mandatory
{
hideExpression: (m: any, v: any, field?: FormlyFieldConfig) =>
field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync,
type: 'btn',
className: 'e2e-disable-encryption-btn',
templateOptions: {
@ -316,22 +318,16 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
btnType: 'primary',
btnStyle: 'stroked',
onClick: async (field: FormlyFieldConfig) => {
const isSuperSync =
field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync;
const result = isSuperSync
? await openDisableEncryptionDialog()
: await openDisableEncryptionDialogForFileBased();
const result = await openDisableEncryptionDialogForFileBased();
if (
result?.success &&
result?.encryptionRemoved &&
field?.parent?.parent?.model
) {
field.parent.parent.model.isEncryptionEnabled = false;
// Also clear encryptKey if we're in file-based provider context
if (!isSuperSync && field?.parent?.parent?.model) {
if (field?.parent?.parent?.model) {
field.parent.parent.model.encryptKey = '';
}
// Close the parent settings dialog
closeAllDialogs();
}
return result?.success ? true : false;
@ -415,6 +411,41 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
field?.parent?.parent?.model?.syncProvider === SyncProviderId.SuperSync,
},
},
// Encryption encouragement warning (shown when encryption is NOT enabled)
// Hidden during initial setup (encryption dialog opens automatically after save)
{
hideExpression: (m: any, v: any, field?: FormlyFieldConfig) =>
field?.parent?.parent?.model?.syncProvider !== SyncProviderId.SuperSync ||
(field?.model?.isEncryptionEnabled ?? false) ||
field?.parent?.parent?.model?._isInitialSetup === true,
type: 'tpl',
templateOptions: {
tag: 'p',
class: 'encryption-warning',
text: T.F.SYNC.FORM.SUPER_SYNC.ENCRYPTION_ENCOURAGED,
},
},
// Enable encryption button for SuperSync (shown when encryption is disabled)
// Hidden during initial setup (encryption dialog opens automatically after save)
{
hideExpression: (m: any, v: any, field?: FormlyFieldConfig) =>
field?.parent?.parent?.model?.syncProvider !== SyncProviderId.SuperSync ||
(field?.model?.isEncryptionEnabled ?? false) ||
field?.parent?.parent?.model?._isInitialSetup === true,
type: 'btn',
className: 'e2e-enable-encryption-btn',
templateOptions: {
text: T.F.SYNC.FORM.SUPER_SYNC.BTN_ENABLE_ENCRYPTION,
btnType: 'primary',
onClick: async (field: FormlyFieldConfig) => {
const result = await openEnableEncryptionDialog();
if (result?.success && field?.model) {
field.model.isEncryptionEnabled = true;
}
return result?.success ? true : false;
},
},
},
// Advanced settings for SuperSync
{
type: 'collapsible',
@ -422,27 +453,6 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
field?.parent?.parent?.model.syncProvider !== SyncProviderId.SuperSync,
props: { label: T.G.ADVANCED_CFG },
fieldGroup: [
// Enable encryption button for SuperSync (shown when encryption is disabled)
{
// Note: Using (m, v, field) signature for btn type fields to ensure
// hideExpression works correctly with the btn component.
// Using ?? false to ensure button stays hidden if field is undefined.
hideExpression: (m: any, v: any, field?: FormlyFieldConfig) =>
field?.model?.isEncryptionEnabled ?? false,
type: 'btn',
className: 'e2e-enable-encryption-btn',
templateOptions: {
text: T.F.SYNC.FORM.SUPER_SYNC.BTN_ENABLE_ENCRYPTION,
btnType: 'primary',
onClick: async (field: FormlyFieldConfig) => {
const result = await openEnableEncryptionDialog();
if (result?.success && field?.model) {
field.model.isEncryptionEnabled = true;
}
return result?.success ? true : false;
},
},
},
// Server URL
{
key: 'baseUrl',

View file

@ -118,6 +118,7 @@ export const globalConfigReducer = createReducer<GlobalConfigState>(
// These settings should remain local to each client:
// - syncProvider: Each client can use different providers (Dropbox, WebDAV, etc.)
// - isEnabled: Each client independently controls whether sync is enabled
// - isEncryptionEnabled: Encryption state must not be overwritten by imports
//
// If oldState.sync.syncProvider is null, we're on first load (using initialGlobalConfigState)
// and should use the incoming values (from snapshot). Otherwise, preserve local values.
@ -131,6 +132,10 @@ export const globalConfigReducer = createReducer<GlobalConfigState>(
? oldState.sync.isEnabled
: incomingSyncConfig.isEnabled;
const isEncryptionEnabled = hasLocalSettings
? oldState.sync.isEncryptionEnabled
: incomingSyncConfig.isEncryptionEnabled;
return {
...appDataComplete.globalConfig,
// Merge defaults for tasks config to fill missing fields.
@ -152,6 +157,7 @@ export const globalConfigReducer = createReducer<GlobalConfigState>(
...incomingSyncConfig,
syncProvider,
isEnabled,
isEncryptionEnabled,
},
};
}),

View file

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

View file

@ -47,33 +47,35 @@
</mat-form-field>
</form>
<mat-divider></mat-divider>
@if (providerType !== 'supersync') {
<mat-divider></mat-divider>
<div class="remove-encryption-section">
<h3>{{ textKeys.L_REMOVE_ENCRYPTION | translate }}</h3>
<p class="remove-warning">
{{ textKeys.REMOVE_ENCRYPTION_WARNING | translate }}
</p>
<button
mat-stroked-button
color="warn"
type="button"
[disabled]="isLoading() || isRemovingEncryption()"
(click)="removeEncryption()"
>
@if (isRemovingEncryption()) {
<mat-spinner
diameter="20"
aria-label="Processing"
></mat-spinner>
} @else {
<ng-container>
<mat-icon aria-hidden="true">lock_open</mat-icon>
{{ textKeys.BTN_DISABLE_ENCRYPTION | translate }}
</ng-container>
}
</button>
</div>
<div class="remove-encryption-section">
<h3>{{ textKeys.L_REMOVE_ENCRYPTION | translate }}</h3>
<p class="remove-warning">
{{ textKeys.REMOVE_ENCRYPTION_WARNING | translate }}
</p>
<button
mat-stroked-button
color="warn"
type="button"
[disabled]="isLoading() || isRemovingEncryption()"
(click)="removeEncryption()"
>
@if (isRemovingEncryption()) {
<mat-spinner
diameter="20"
aria-label="Processing"
></mat-spinner>
} @else {
<ng-container>
<mat-icon aria-hidden="true">lock_open</mat-icon>
{{ textKeys.BTN_DISABLE_ENCRYPTION | translate }}
</ng-container>
}
</button>
</div>
}
} @else {
<div class="warning-box">
<mat-icon aria-hidden="true">warning</mat-icon>

View file

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

View file

@ -1,76 +0,0 @@
<h1 mat-dialog-title>
{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_TITLE | translate }}
</h1>
@if (!canProceed()) {
<mat-dialog-content>
<div class="info-box">
<mat-icon aria-hidden="true">info</mat-icon>
<div>
<p>{{ errorReason() | translate }}</p>
<p>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ENABLE_FIRST | translate }}</p>
</div>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
type="button"
(click)="cancel()"
>
{{ T.G.CLOSE | translate }}
</button>
</mat-dialog-actions>
} @else {
<mat-dialog-content>
<div class="warning-box">
<mat-icon aria-hidden="true">warning</mat-icon>
<p>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_WARNING | translate }}</p>
</div>
<div class="info-section">
<h3>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_WHAT_HAPPENS | translate }}</h3>
<ul>
<li>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ITEM_1 | translate }}</li>
<li>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ITEM_2 | translate }}</li>
<li>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_ITEM_3 | translate }}</li>
</ul>
</div>
<p class="note">
<mat-icon aria-hidden="true">info</mat-icon>
<span>{{ T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_NOTE | translate }}</span>
</p>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
type="button"
[disabled]="isLoading()"
(click)="cancel()"
>
{{ T.G.CANCEL | translate }}
</button>
<button
mat-flat-button
color="warn"
type="button"
[disabled]="isLoading()"
(click)="confirm()"
>
@if (isLoading()) {
<mat-spinner
diameter="20"
aria-label="Processing"
></mat-spinner>
} @else {
<ng-container>
<mat-icon aria-hidden="true">lock_open</mat-icon>
{{ T.F.SYNC.FORM.SUPER_SYNC.BTN_DISABLE_ENCRYPTION | translate }}
</ng-container>
}
</button>
</mat-dialog-actions>
}

View file

@ -1,3 +0,0 @@
@use '../dialog-encryption-common' as *;
@include dialog-encryption-common-classes;

View file

@ -1,98 +0,0 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import {
MatDialogActions,
MatDialogContent,
MatDialogRef,
MatDialogTitle,
} from '@angular/material/dialog';
import { T } from '../../../t.const';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { EncryptionDisableService } from '../encryption-disable.service';
import { SnackService } from '../../../core/snack/snack.service';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { SyncProviderManager } from '../../../op-log/sync-providers/provider-manager.service';
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
export interface DisableEncryptionResult {
success: boolean;
}
@Component({
selector: 'dialog-disable-encryption',
templateUrl: './dialog-disable-encryption.component.html',
styleUrls: ['./dialog-disable-encryption.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatButton,
MatIcon,
TranslatePipe,
MatProgressSpinner,
],
})
export class DialogDisableEncryptionComponent {
private _encryptionDisableService = inject(EncryptionDisableService);
private _snackService = inject(SnackService);
private _providerManager = inject(SyncProviderManager);
private _matDialogRef =
inject<MatDialogRef<DialogDisableEncryptionComponent, DisableEncryptionResult>>(
MatDialogRef,
);
T: typeof T = T;
isLoading = signal(false);
canProceed = signal(true);
errorReason = signal<string | null>(null);
constructor() {
this._checkPreconditions();
}
private _checkPreconditions(): void {
const provider = this._providerManager.getActiveProvider();
if (!provider) {
this.canProceed.set(false);
this.errorReason.set(T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_NOT_READY);
return;
}
if (provider.id !== SyncProviderId.SuperSync) {
this.canProceed.set(false);
this.errorReason.set(T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_SUPERSYNC_ONLY);
return;
}
}
async confirm(): Promise<void> {
if (this.isLoading() || !this.canProceed()) {
return;
}
this.isLoading.set(true);
try {
await this._encryptionDisableService.disableEncryption();
this._snackService.open({
type: 'SUCCESS',
msg: T.F.SYNC.FORM.SUPER_SYNC.DISABLE_ENCRYPTION_SUCCESS,
});
this._matDialogRef.close({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this._snackService.open({
type: 'ERROR',
msg: `Failed to disable encryption: ${message}`,
});
this.isLoading.set(false);
}
}
cancel(): void {
this._matDialogRef.close({ success: false });
}
}

View file

@ -1,5 +1,9 @@
<h1 mat-dialog-title>
{{ textKeys.ENABLE_ENCRYPTION_TITLE | translate }}
@if (initialSetup) {
{{ textKeys.SETUP_ENCRYPTION_TITLE | translate }}
} @else {
{{ textKeys.ENABLE_ENCRYPTION_TITLE | translate }}
}
</h1>
@if (!canProceed()) {
@ -21,7 +25,91 @@
{{ T.G.CLOSE | translate }}
</button>
</mat-dialog-actions>
} @else if (initialSetup) {
<!-- Simplified dialog for initial SuperSync setup -->
<mat-dialog-content>
<div class="info-box">
<mat-icon aria-hidden="true">enhanced_encryption</mat-icon>
<div>
<p [innerHTML]="textKeys.ENCRYPTION_SETUP_INTRO | translate"></p>
<p>{{ textKeys.SETUP_ENCRYPTION_PASSWORD_WARNING | translate }}</p>
</div>
</div>
<div class="password-fields">
<mat-form-field appearance="outline">
<mat-label>{{ T.F.SYNC.FORM.L_ENCRYPTION_PASSWORD | translate }}</mat-label>
<input
matInput
[type]="showPassword() ? 'text' : 'password'"
[(ngModel)]="password"
[ngModelOptions]="{ standalone: true }"
autocomplete="new-password"
/>
<button
mat-icon-button
matSuffix
type="button"
[attr.aria-label]="T.G.TOGGLE_PASSWORD_VISIBILITY | translate"
(click)="showPassword.set(!showPassword())"
>
<mat-icon aria-hidden="true">{{
showPassword() ? 'visibility_off' : 'visibility'
}}</mat-icon>
</button>
</mat-form-field>
<password-strength [password]="password" />
<mat-form-field appearance="outline">
<mat-label>{{ textKeys.L_CONFIRM_PASSWORD | translate }}</mat-label>
<input
matInput
[type]="showPassword() ? 'text' : 'password'"
[(ngModel)]="confirmPassword"
[ngModelOptions]="{ standalone: true }"
autocomplete="new-password"
/>
</mat-form-field>
@if (passwordError) {
<div class="password-error">
<mat-icon aria-hidden="true">error</mat-icon>
<span>{{ passwordError | translate }}</span>
</div>
}
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
type="button"
[disabled]="isLoading()"
(click)="disableSuperSync()"
>
{{ textKeys.SETUP_DISABLE_SUPERSYNC_BTN | translate }}
</button>
<button
mat-flat-button
color="primary"
type="button"
[disabled]="isLoading() || !isPasswordValid"
(click)="confirm()"
>
@if (isLoading()) {
<mat-spinner
diameter="20"
[attr.aria-label]="T.G.PROCESSING | translate"
></mat-spinner>
} @else {
<mat-icon aria-hidden="true">lock</mat-icon>
{{ textKeys.SETUP_ENCRYPTION_BTN | translate }}
}
</button>
</mat-dialog-actions>
} @else {
<!-- Full dialog for enabling encryption on existing setup -->
<mat-dialog-content>
<div class="warning-box">
<mat-icon aria-hidden="true">warning</mat-icon>
@ -33,18 +121,33 @@
<mat-label>{{ T.F.SYNC.FORM.L_ENCRYPTION_PASSWORD | translate }}</mat-label>
<input
matInput
type="password"
[type]="showPassword() ? 'text' : 'password'"
[(ngModel)]="password"
[ngModelOptions]="{ standalone: true }"
autocomplete="new-password"
/>
<button
mat-icon-button
matSuffix
type="button"
[attr.aria-label]="T.G.TOGGLE_PASSWORD_VISIBILITY | translate"
(click)="showPassword.set(!showPassword())"
>
<mat-icon aria-hidden="true">{{
showPassword() ? 'visibility_off' : 'visibility'
}}</mat-icon>
</button>
</mat-form-field>
<password-strength [password]="password" />
<mat-form-field appearance="outline">
<mat-label>{{ textKeys.L_CONFIRM_PASSWORD | translate }}</mat-label>
<input
matInput
type="password"
[type]="showPassword() ? 'text' : 'password'"
[(ngModel)]="confirmPassword"
[ngModelOptions]="{ standalone: true }"
autocomplete="new-password"
/>
</mat-form-field>
@ -91,13 +194,11 @@
@if (isLoading()) {
<mat-spinner
diameter="20"
aria-label="Processing"
[attr.aria-label]="T.G.PROCESSING | translate"
></mat-spinner>
} @else {
<ng-container>
<mat-icon aria-hidden="true">lock</mat-icon>
{{ textKeys.BTN_ENABLE_ENCRYPTION | translate }}
</ng-container>
<mat-icon aria-hidden="true">lock</mat-icon>
{{ textKeys.BTN_ENABLE_ENCRYPTION | translate }}
}
</button>
</mat-dialog-actions>

View file

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

View file

@ -10,20 +10,25 @@ import { T } from '../../../t.const';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { EncryptionEnableService } from '../encryption-enable.service';
import { SuperSyncEncryptionToggleService } from '../supersync-encryption-toggle.service';
import { SnackService } from '../../../core/snack/snack.service';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { SyncProviderManager } from '../../../op-log/sync-providers/provider-manager.service';
import { SyncWrapperService } from '../sync-wrapper.service';
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
import { isFileBasedProvider } from '../../../op-log/sync/operation-sync.util';
import { FileBasedEncryptionService } from '../file-based-encryption.service';
import { FormsModule } from '@angular/forms';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatFormField, MatLabel, MatSuffix } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatIconButton } from '@angular/material/button';
import { GlobalConfigService } from '../../../features/config/global-config.service';
import { PasswordStrengthComponent } from '../../../ui/password-strength/password-strength.component';
export interface EnableEncryptionDialogData {
encryptKey?: string;
providerType?: 'supersync' | 'file-based';
initialSetup?: boolean;
}
export interface EnableEncryptionResult {
@ -47,13 +52,18 @@ export interface EnableEncryptionResult {
MatFormField,
MatLabel,
MatInput,
MatSuffix,
MatIconButton,
PasswordStrengthComponent,
],
})
export class DialogEnableEncryptionComponent {
private _encryptionEnableService = inject(EncryptionEnableService);
private _encryptionToggleService = inject(SuperSyncEncryptionToggleService);
private _fileBasedEncryptionService = inject(FileBasedEncryptionService);
private _snackService = inject(SnackService);
private _providerManager = inject(SyncProviderManager);
private _syncWrapperService = inject(SyncWrapperService);
private _globalConfigService = inject(GlobalConfigService);
private _data = inject<EnableEncryptionDialogData | null>(MAT_DIALOG_DATA, {
optional: true,
});
@ -66,6 +76,8 @@ export class DialogEnableEncryptionComponent {
isLoading = signal(false);
canProceed = signal(true);
errorReason = signal<string | null>(null);
showPassword = signal(false);
initialSetup: boolean = this._data?.initialSetup || false;
providerType: 'supersync' | 'file-based' = this._data?.providerType || 'supersync';
textKeys: Record<string, string> =
this.providerType === 'file-based'
@ -80,7 +92,9 @@ export class DialogEnableEncryptionComponent {
readonly MIN_PASSWORD_LENGTH = 8;
constructor() {
this._checkPreconditions();
if (!this.initialSetup) {
this._checkPreconditions();
}
}
get isPasswordValid(): boolean {
@ -131,10 +145,16 @@ export class DialogEnableEncryptionComponent {
try {
if (this.providerType === 'file-based') {
await this._fileBasedEncryptionService.enableEncryption(this.password);
await this._syncWrapperService.runWithSyncBlocked(() =>
this._fileBasedEncryptionService.enableEncryption(this.password),
);
} else {
await this._encryptionEnableService.enableEncryption(this.password);
await this._syncWrapperService.runWithSyncBlocked(() =>
this._encryptionToggleService.enableEncryption(this.password),
);
}
this.password = '';
this.confirmPassword = '';
this._snackService.open({
type: 'SUCCESS',
msg: this.textKeys.ENABLE_ENCRYPTION_SUCCESS,
@ -144,12 +164,18 @@ export class DialogEnableEncryptionComponent {
const message = error instanceof Error ? error.message : 'Unknown error';
this._snackService.open({
type: 'ERROR',
msg: `Failed to enable encryption: ${message}`,
msg: T.F.SYNC.S.ENABLE_ENCRYPTION_FAILED,
translateParams: { message },
});
this.isLoading.set(false);
}
}
disableSuperSync(): void {
this._globalConfigService.updateSection('sync', { isEnabled: false });
this._matDialogRef.close({ success: false });
}
cancel(): void {
this._matDialogRef.close({ success: false });
}

View file

@ -22,6 +22,12 @@
/>
</mat-form-field>
</form>
@if (isSuperSync()) {
<p class="force-overwrite-info">
{{ T.F.SYNC.D_ENTER_PASSWORD.FORCE_OVERWRITE_INFO | translate }}
</p>
}
</div>
</mat-dialog-content>
@ -32,10 +38,22 @@
[disabled]="isLoading()"
(click)="cancel()"
>
<mat-icon>close</mat-icon>
{{ T.G.CANCEL | translate }}
</button>
@if (isSuperSync()) {
<button
mat-flat-button
color="warn"
type="button"
[disabled]="!passwordVal || isLoading()"
(click)="forceOverwrite()"
>
<mat-icon aria-hidden="true">cloud_upload</mat-icon>
{{ T.F.SYNC.D_ENTER_PASSWORD.BTN_FORCE_OVERWRITE | translate }}
</button>
}
<button
mat-flat-button
color="primary"
@ -43,7 +61,7 @@
[disabled]="!passwordVal || isLoading()"
(click)="saveAndSync()"
>
<mat-icon>sync</mat-icon>
<mat-icon aria-hidden="true">sync</mat-icon>
{{ T.F.SYNC.D_ENTER_PASSWORD.BTN_SAVE_AND_SYNC | translate }}
</button>
</mat-dialog-actions>

View file

@ -6,7 +6,7 @@
margin-bottom: 16px;
}
.force-overwrite-note {
.force-overwrite-info {
margin-top: 12px;
font-size: 13px;
}

View file

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

View file

@ -13,6 +13,8 @@ import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { SyncConfigService } from '../sync-config.service';
import { SnackService } from '../../../core/snack/snack.service';
import { SyncLog } from '../../../core/log';
@Component({
selector: 'dialog-handle-decrypt-error',
@ -34,6 +36,7 @@ import { SyncConfigService } from '../sync-config.service';
})
export class DialogHandleDecryptErrorComponent {
private _syncConfigService = inject(SyncConfigService);
private _snackService = inject(SnackService);
private _matDialogRef =
inject<MatDialogRef<DialogHandleDecryptErrorComponent>>(MatDialogRef);
@ -42,16 +45,35 @@ export class DialogHandleDecryptErrorComponent {
passwordVal: string = '';
async updatePWAndForceUpload(): Promise<void> {
await this._syncConfigService.updateEncryptionPassword(this.passwordVal);
this._matDialogRef.close({ isForceUpload: true });
try {
await this._syncConfigService.updateEncryptionPassword(this.passwordVal);
this.passwordVal = '';
this._matDialogRef.close({ isForceUpload: true });
} catch (error) {
SyncLog.err('Failed to save encryption password for force upload', error);
this._snackService.open({
type: 'ERROR',
msg: T.F.SYNC.S.PERSIST_FAILED,
});
}
}
async updatePwAndResync(): Promise<void> {
await this._syncConfigService.updateEncryptionPassword(this.passwordVal);
this._matDialogRef.close({ isReSync: true });
try {
await this._syncConfigService.updateEncryptionPassword(this.passwordVal);
this.passwordVal = '';
this._matDialogRef.close({ isReSync: true });
} catch (error) {
SyncLog.err('Failed to save encryption password for resync', error);
this._snackService.open({
type: 'ERROR',
msg: T.F.SYNC.S.PERSIST_FAILED,
});
}
}
cancel(): void {
this.passwordVal = '';
this._matDialogRef.close({});
}
}

View file

@ -1,55 +0,0 @@
<mat-dialog-content>
<div style="text-align: center; margin-bottom: 16px">
<mat-icon
color="warn"
style="font-size: 48px; height: 48px; width: 48px"
>error
</mat-icon>
</div>
<p><strong>The date of your remote data are from the future</strong></p>
<p>You should check the time configured on your systems!</p>
<div style="text-align: center; margin-bottom: 16px">
<button
(click)="downloadBackup()"
color="primary"
mat-stroked-button
type="button"
>
<mat-icon>file_download</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP | translate }}
</button>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<div class="wrap-buttons">
<button
(click)="close()"
color="primary"
mat-button
>
{{ T.G.CANCEL | translate }}
</button>
<button
(click)="close('FORCE_UPDATE_LOCAL')"
color="warn"
mat-stroked-button
>
<mat-icon>file_download</mat-icon>
Force Download Remote
<!-- {{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}-->
</button>
<button
(click)="close('FORCE_UPDATE_REMOTE')"
color="warn"
mat-stroked-button
>
<mat-icon>file_upload</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}
</button>
</div>
</mat-dialog-actions>

View file

@ -1,74 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
} from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { FormsModule } from '@angular/forms';
import { download } from '../../../util/download';
import { IS_ELECTRON } from '../../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { MatIcon } from '@angular/material/icon';
import { MatButton } from '@angular/material/button';
import { BackupService } from '../../../op-log/backup/backup.service';
import { T } from '../../../t.const';
import { Log } from '../../../core/log';
export interface DialogIncompleteSyncData {
modelId: string;
}
@Component({
selector: 'dialog-incoherent-timestamps-error',
imports: [
MatDialogContent,
TranslateModule,
FormsModule,
MatIcon,
MatDialogActions,
MatButton,
],
templateUrl: './dialog-incoherent-timestamps-error.component.html',
styleUrl: './dialog-incoherent-timestamps-error.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DialogIncoherentTimestampsErrorComponent {
private _matDialogRef =
inject<MatDialogRef<DialogIncoherentTimestampsErrorComponent>>(MatDialogRef);
private _backupService = inject(BackupService);
data = inject<DialogIncompleteSyncData>(MAT_DIALOG_DATA);
T: typeof T = T;
IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW;
constructor() {
const _matDialogRef = this._matDialogRef;
_matDialogRef.disableClose = true;
}
async downloadBackup(): Promise<void> {
const data = await this._backupService.loadCompleteBackup(true);
try {
await download('super-productivity-backup.json', JSON.stringify(data));
} catch (e) {
Log.error(e);
}
// download('super-productivity-backup.json', privacyExport(data));
}
close(res?: 'FORCE_UPDATE_REMOTE' | 'FORCE_UPDATE_LOCAL'): void {
this._matDialogRef.close(res);
}
closeApp(): void {
if (IS_ELECTRON) {
window.ea.shutdownNow();
} else {
window.close();
}
}
}

View file

@ -1,71 +0,0 @@
<mat-dialog-content>
<div style="text-align: center; margin-bottom: 16px">
<mat-icon
color="warn"
style="font-size: 48px; height: 48px; width: 48px"
>error
</mat-icon>
</div>
<p>
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.T1 | translate }} </strong>
</p>
<p>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.T2 | translate }}
<code
>mfRev: {{ data?.archiveRevInMainFile }} & realRev: {{ data?.archiveRevReal }}</code
>
</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}</p>
<p style="text-align: center; margin-top: 16px">
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} </strong>
</p>
<div style="text-align: center; margin-bottom: 16px">
<button
(click)="downloadBackup()"
color="primary"
mat-stroked-button
type="button"
>
<mat-icon>file_download</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP | translate }}
</button>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<div class="wrap-buttons">
<button
(click)="close()"
color="primary"
mat-stroked-button
>
{{ T.G.CANCEL | translate }}
</button>
@if (!IS_ANDROID_WEB_VIEW) {
<button
(click)="closeApp()"
color="primary"
mat-stroked-button
>
<mat-icon>power_settings_new</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_CLOSE_APP | translate }}
</button>
}
<button
(click)="close('FORCE_UPDATE_REMOTE')"
color="warn"
mat-stroked-button
>
<mat-icon>file_upload</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}
</button>
</div>
</mat-dialog-actions>

View file

@ -1,7 +0,0 @@
:host > * {
max-width: 500px;
}
:host code {
font-size: 10px;
}

View file

@ -1,75 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogContent,
MatDialogRef,
} from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { FormsModule } from '@angular/forms';
import { download } from '../../../util/download';
import { IS_ELECTRON } from '../../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { MatIcon } from '@angular/material/icon';
import { MatButton } from '@angular/material/button';
import { BackupService } from '../../../op-log/backup/backup.service';
import { T } from '../../../t.const';
import { Log } from '../../../core/log';
export interface DialogIncompleteSyncData {
archiveRevInMainFile?: string;
archiveRevReal?: string;
}
@Component({
selector: 'dialog-incomplete-sync-old',
imports: [
MatDialogContent,
TranslateModule,
FormsModule,
MatIcon,
MatDialogActions,
MatButton,
],
templateUrl: './dialog-incomplete-sync-old.component.html',
styleUrl: './dialog-incomplete-sync-old.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DialogIncompleteSyncOldComponent {
private _matDialogRef =
inject<MatDialogRef<DialogIncompleteSyncOldComponent>>(MatDialogRef);
private _backupService = inject(BackupService);
data? = inject<DialogIncompleteSyncData>(MAT_DIALOG_DATA);
T: typeof T = T;
IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW;
constructor() {
const _matDialogRef = this._matDialogRef;
_matDialogRef.disableClose = true;
}
async downloadBackup(): Promise<void> {
const data = await this._backupService.loadCompleteBackup(true);
try {
await download('super-productivity-backup.json', JSON.stringify(data));
} catch (e) {
Log.error(e);
}
// download('super-productivity-backup.json', privacyExport(data));
}
close(res?: 'FORCE_UPDATE_REMOTE'): void {
this._matDialogRef.close(res);
}
closeApp(): void {
if (IS_ELECTRON) {
window.ea.shutdownNow();
} else {
window.close();
}
}
}

View file

@ -1,70 +0,0 @@
<mat-dialog-content>
<div style="text-align: center; margin-bottom: 16px">
<mat-icon
color="warn"
style="font-size: 48px; height: 48px; width: 48px"
>error
</mat-icon>
</div>
<p>
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.P1 | translate }} </strong>
</p>
<p>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.P2 | translate }}
<strong>{{ data?.modelId }}</strong>
</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}</p>
<p style="text-align: center; margin-top: 16px">
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} </strong>
</p>
<div style="text-align: center; margin-bottom: 16px">
<button
(click)="downloadBackup()"
color="primary"
mat-stroked-button
type="button"
>
<mat-icon>file_download</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP | translate }}
</button>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<div class="wrap-buttons">
<button
(click)="close()"
color="primary"
mat-button
>
{{ T.G.CANCEL | translate }}
</button>
@if (!IS_ANDROID_WEB_VIEW) {
<button
(click)="closeApp()"
color="primary"
mat-stroked-button
>
<mat-icon>power_settings_new</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_CLOSE_APP | translate }}
</button>
}
<button
(click)="close('FORCE_UPDATE_REMOTE')"
color="warn"
mat-stroked-button
>
<mat-icon>file_upload</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}
</button>
</div>
</mat-dialog-actions>

View file

@ -1,7 +0,0 @@
:host > * {
max-width: 500px;
}
:host code {
font-size: 10px;
}

View file

@ -0,0 +1,90 @@
<mat-dialog-content>
<div style="text-align: center; margin-bottom: 16px">
<mat-icon
color="warn"
style="font-size: 48px; height: 48px; width: 48px"
aria-hidden="true"
>error
</mat-icon>
</div>
@if (data.type === 'incomplete-sync') {
<p>
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.P1 | translate }} </strong>
</p>
<p>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.P2 | translate }}
<strong>{{ data.modelId }}</strong>
</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T3 | translate }}</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T4 | translate }}</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.T5 | translate }}</p>
<p style="text-align: center; margin-top: 16px">
<strong> {{ T.F.SYNC.D_INCOMPLETE_SYNC.T6 | translate }} </strong>
</p>
} @else {
<p>
<strong>{{ T.F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P1 | translate }}</strong>
</p>
<p>{{ T.F.SYNC.D_INCOMPLETE_SYNC.INCOHERENT_P2 | translate }}</p>
}
<div style="text-align: center; margin-bottom: 16px">
<button
(click)="downloadBackup()"
color="primary"
mat-stroked-button
type="button"
>
<mat-icon aria-hidden="true">file_download</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_DOWNLOAD_BACKUP | translate }}
</button>
</div>
</mat-dialog-content>
<mat-dialog-actions align="end">
<div class="wrap-buttons">
<button
(click)="close()"
color="primary"
mat-button
>
{{ T.G.CANCEL | translate }}
</button>
@if (data.type === 'incomplete-sync' && !IS_ANDROID_WEB_VIEW) {
<button
(click)="closeApp()"
color="primary"
mat-stroked-button
>
<mat-icon aria-hidden="true">power_settings_new</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_CLOSE_APP | translate }}
</button>
}
@if (data.type === 'incoherent-timestamps') {
<button
(click)="close('FORCE_UPDATE_LOCAL')"
color="warn"
mat-stroked-button
>
<mat-icon aria-hidden="true">file_download</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_DOWNLOAD | translate }}
</button>
}
<button
(click)="close('FORCE_UPDATE_REMOTE')"
color="warn"
mat-stroked-button
>
<mat-icon aria-hidden="true">file_upload</mat-icon>
{{ T.F.SYNC.D_INCOMPLETE_SYNC.BTN_FORCE_UPLOAD | translate }}
</button>
</div>
</mat-dialog-actions>

View file

@ -6,7 +6,6 @@ import {
MatDialogRef,
} from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { FormsModule } from '@angular/forms';
import { download } from '../../../util/download';
import { IS_ELECTRON } from '../../../app.constants';
@ -17,37 +16,36 @@ import { BackupService } from '../../../op-log/backup/backup.service';
import { T } from '../../../t.const';
import { Log } from '../../../core/log';
export interface DialogIncompleteSyncData {
modelId: string;
export type DialogSyncErrorType = 'incomplete-sync' | 'incoherent-timestamps';
export interface DialogSyncErrorData {
type: DialogSyncErrorType;
modelId?: string;
}
export type DialogSyncErrorResult =
| 'FORCE_UPDATE_REMOTE'
| 'FORCE_UPDATE_LOCAL'
| undefined;
@Component({
selector: 'dialog-incomplete-sync',
imports: [
MatDialogContent,
TranslateModule,
FormsModule,
MatIcon,
MatDialogActions,
MatButton,
],
templateUrl: './dialog-incomplete-sync.component.html',
styleUrl: './dialog-incomplete-sync.component.scss',
selector: 'dialog-sync-error',
imports: [MatDialogContent, TranslateModule, MatIcon, MatDialogActions, MatButton],
templateUrl: './dialog-sync-error.component.html',
styleUrl: './dialog-sync-error.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DialogIncompleteSyncComponent {
private _matDialogRef =
inject<MatDialogRef<DialogIncompleteSyncComponent>>(MatDialogRef);
export class DialogSyncErrorComponent {
private _matDialogRef = inject<MatDialogRef<DialogSyncErrorComponent>>(MatDialogRef);
private _backupService = inject(BackupService);
data = inject<DialogIncompleteSyncData>(MAT_DIALOG_DATA);
data = inject<DialogSyncErrorData>(MAT_DIALOG_DATA);
T: typeof T = T;
IS_ANDROID_WEB_VIEW = IS_ANDROID_WEB_VIEW;
constructor() {
const _matDialogRef = this._matDialogRef;
_matDialogRef.disableClose = true;
this._matDialogRef.disableClose = true;
}
async downloadBackup(): Promise<void> {
@ -57,10 +55,9 @@ export class DialogIncompleteSyncComponent {
} catch (e) {
Log.error(e);
}
// download('super-productivity-backup.json', privacyExport(data));
}
close(res?: 'FORCE_UPDATE_REMOTE'): void {
close(res?: DialogSyncErrorResult): void {
this._matDialogRef.close(res);
}

View file

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

View file

@ -1,231 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
import { SyncLog } from '../../core/log';
import { SnapshotUploadService } from './snapshot-upload.service';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { StateSnapshotService } from '../../op-log/backup/state-snapshot.service';
import { VectorClockService } from '../../op-log/sync/vector-clock.service';
import {
CLIENT_ID_PROVIDER,
ClientIdProvider,
} from '../../op-log/util/client-id.provider';
import { CURRENT_SCHEMA_VERSION } from '../../op-log/persistence/schema-migration.service';
import { uuidv7 } from '../../util/uuid-v7';
import { isFileBasedProvider } from '../../op-log/sync/operation-sync.util';
import { FileBasedSyncAdapterService } from '../../op-log/sync-providers/file-based/file-based-sync-adapter.service';
import { GlobalConfigService } from '../../features/config/global-config.service';
const LOG_PREFIX = 'EncryptionDisableService';
/**
* Service for disabling encryption for sync providers.
*
* ## SuperSync
* Disable encryption flow:
* 1. Delete all data on server (encrypted operations can't be mixed with unencrypted)
* 2. Upload current state as unencrypted snapshot
* 3. Update local config to disable encryption and clear the key
*
* ## File-based providers (Dropbox, WebDAV, LocalFile)
* Disable encryption flow:
* 1. Get current local state snapshot
* 2. Upload unencrypted snapshot (replace encrypted sync file)
* 3. Update local config to disable encryption and clear the key
*/
@Injectable({
providedIn: 'root',
})
export class EncryptionDisableService {
private _snapshotUploadService = inject(SnapshotUploadService);
private _wrappedProviderService = inject(WrappedProviderService);
private _providerManager = inject(SyncProviderManager);
private _stateSnapshotService = inject(StateSnapshotService);
private _vectorClockService = inject(VectorClockService);
private _clientIdProvider: ClientIdProvider = inject(CLIENT_ID_PROVIDER);
private _fileBasedAdapter = inject(FileBasedSyncAdapterService);
private _globalConfigService = inject(GlobalConfigService);
/**
* Disables encryption by deleting all server data
* and uploading a new unencrypted snapshot.
*
* @throws Error if sync provider is not SuperSync or not ready
*/
async disableEncryption(): Promise<void> {
SyncLog.normal(`${LOG_PREFIX}: Starting encryption disable...`);
// Gather all data needed for upload (validates provider)
const { syncProvider, existingCfg, state, vectorClock, clientId } =
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
// Delete all server data (encrypted ops can't be mixed with unencrypted)
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
await syncProvider.deleteAllData();
// Upload unencrypted snapshot
SyncLog.normal(`${LOG_PREFIX}: Uploading unencrypted snapshot...`);
try {
const result = await this._snapshotUploadService.uploadSnapshot(
syncProvider,
state,
clientId,
vectorClock,
false, // isPayloadEncrypted = false
);
if (!result.accepted) {
throw new Error(`Snapshot upload failed: ${result.error}`);
}
// Update local config AFTER successful upload - disable encryption and clear the key
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
await syncProvider.setPrivateCfg({
...existingCfg,
encryptKey: undefined,
isEncryptionEnabled: false,
} as SuperSyncPrivateCfg);
// Clear cached adapters to ensure new encryption settings take effect
this._wrappedProviderService.clearCache();
// Update lastServerSeq
await this._snapshotUploadService.updateLastServerSeq(
syncProvider,
result.serverSeq,
LOG_PREFIX,
);
SyncLog.normal(`${LOG_PREFIX}: Encryption disabled successfully!`);
} catch (uploadError) {
// CRITICAL: Server data was deleted but new snapshot failed to upload.
SyncLog.err(
`${LOG_PREFIX}: Snapshot upload failed after deleting server data!`,
uploadError,
);
throw new Error(
'CRITICAL: Failed to upload unencrypted snapshot after deleting server data. ' +
'Your local data is safe. Please use "Sync Now" to re-upload your data. ' +
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
);
}
}
/**
* Disables encryption for file-based sync providers (Dropbox, WebDAV, LocalFile).
*
* Flow:
* 1. Validate provider is file-based and ready
* 2. Get current local state snapshot
* 3. Create unencrypted adapter and upload snapshot
* 4. Update local config to disable encryption and clear the key
* 5. Clear caches
*
* @throws Error if sync provider is not file-based or not ready
*/
async disableEncryptionForFileBased(): Promise<void> {
SyncLog.normal(
`${LOG_PREFIX}: Starting encryption disable for file-based provider...`,
);
// Get active provider
const provider = this._providerManager.getActiveProvider();
if (!provider) {
throw new Error('No active sync provider. Please enable sync first.');
}
// Validate it's a file-based provider
if (!isFileBasedProvider(provider)) {
throw new Error(
`This operation is only supported for file-based providers (Dropbox, WebDAV, LocalFile). ` +
`Current provider: ${provider.id}`,
);
}
// Check provider is ready
if (!(await provider.isReady())) {
throw new Error('Sync provider is not ready. Please configure sync first.');
}
// Get current state
SyncLog.normal(`${LOG_PREFIX}: Getting current state...`);
const state = await this._stateSnapshotService.getStateSnapshotAsync();
const vectorClock = await this._vectorClockService.getCurrentVectorClock();
const clientId = await this._clientIdProvider.loadClientId();
if (!clientId) {
throw new Error('Client ID not available');
}
// Get existing config
const existingCfg = await provider.privateCfg.load();
// Create unencrypted adapter (pass undefined for encryptKey)
SyncLog.normal(`${LOG_PREFIX}: Creating unencrypted adapter...`);
const baseCfg = this._providerManager.getEncryptAndCompressCfg();
const unencryptedCfg = {
...baseCfg,
isEncrypt: false, // Explicitly disable encryption
};
const adapter = this._fileBasedAdapter.createAdapter(
provider,
unencryptedCfg,
undefined, // No encryption key
);
// Upload unencrypted snapshot
SyncLog.normal(`${LOG_PREFIX}: Uploading unencrypted snapshot...`);
try {
const result = await adapter.uploadSnapshot(
state,
clientId,
'recovery',
vectorClock,
CURRENT_SCHEMA_VERSION,
false, // isPayloadEncrypted = false
uuidv7(),
);
if (!result.accepted) {
throw new Error(`Snapshot upload failed: ${result.error}`);
}
// Update local config AFTER successful upload
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
// Update provider's private config (encryptKey)
await provider.setPrivateCfg({
...existingCfg,
encryptKey: undefined,
});
// Update global sync config (isEncryptionEnabled, encryptKey)
this._globalConfigService.updateSection('sync', {
isEncryptionEnabled: false,
encryptKey: '',
});
// Clear cached adapters to ensure new encryption settings take effect
this._wrappedProviderService.clearCache();
// Update lastServerSeq
if (result.serverSeq !== undefined) {
await adapter.setLastServerSeq(result.serverSeq);
}
SyncLog.normal(
`${LOG_PREFIX}: Encryption disabled successfully for file-based provider!`,
);
} catch (uploadError) {
SyncLog.err(`${LOG_PREFIX}: Failed to upload unencrypted snapshot!`, uploadError);
throw new Error(
'Failed to upload unencrypted snapshot. ' +
'Your local data is safe. Please try again. ' +
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
);
}
}
}

View file

@ -1,140 +0,0 @@
import { inject, Injectable } from '@angular/core';
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
import { SyncLog } from '../../core/log';
import { SnapshotUploadService } from './snapshot-upload.service';
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption';
import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors';
const LOG_PREFIX = 'EncryptionEnableService';
/**
* Service for enabling encryption for SuperSync.
*
* Enable encryption flow:
* 1. Delete all data on server (unencrypted operations can't be mixed with encrypted)
* 2. Update local config BEFORE upload (so upload uses the new key)
* 3. Upload current state as encrypted snapshot
* 4. Revert config on failure
*/
@Injectable({
providedIn: 'root',
})
export class EncryptionEnableService {
private _snapshotUploadService = inject(SnapshotUploadService);
private _encryptionService = inject(OperationEncryptionService);
private _wrappedProviderService = inject(WrappedProviderService);
private _providerManager = inject(SyncProviderManager);
/**
* Enables encryption by deleting all server data
* and uploading a new encrypted snapshot.
*
* @param encryptKey The encryption key to use
* @throws Error if sync provider is not SuperSync or not ready
*/
async enableEncryption(encryptKey: string): Promise<void> {
SyncLog.normal(`${LOG_PREFIX}: Starting encryption enable...`);
if (!encryptKey) {
throw new Error('Encryption key is required');
}
// CRITICAL: Check crypto availability BEFORE deleting server data
// to prevent data loss if encryption will fail
if (!isCryptoSubtleAvailable()) {
throw new WebCryptoNotAvailableError(
'Cannot enable encryption: WebCrypto API is not available. ' +
'Encryption requires a secure context (HTTPS). ' +
'On Android, encryption is not supported.',
);
}
// Gather all data needed for upload (validates provider)
const { syncProvider, existingCfg, state, vectorClock, clientId } =
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
// Delete all server data (unencrypted ops can't be mixed with encrypted)
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
await syncProvider.deleteAllData();
// Update local config BEFORE upload - enable encryption and set the key
// This must happen BEFORE upload so the upload uses the new key
// IMPORTANT: Use providerManager.setProviderConfig() instead of direct setPrivateCfg()
// to ensure the currentProviderPrivateCfg$ observable is updated, which is needed
// for the form to correctly show isEncryptionEnabled state.
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
const newConfig = {
...existingCfg,
encryptKey,
isEncryptionEnabled: true,
} as SuperSyncPrivateCfg;
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, newConfig);
// Clear cached adapters to ensure new encryption settings take effect
this._wrappedProviderService.clearCache();
// Encrypt the snapshot payload
SyncLog.normal(`${LOG_PREFIX}: Encrypting snapshot...`);
const encryptedPayload = await this._encryptionService.encryptPayload(
state,
encryptKey,
);
// Upload encrypted snapshot
SyncLog.normal(`${LOG_PREFIX}: Uploading encrypted snapshot...`);
try {
const result = await this._snapshotUploadService.uploadSnapshot(
syncProvider,
encryptedPayload,
clientId,
vectorClock,
true, // isPayloadEncrypted = true
);
if (!result.accepted) {
throw new Error(`Snapshot upload failed: ${result.error}`);
}
// Update lastServerSeq
await this._snapshotUploadService.updateLastServerSeq(
syncProvider,
result.serverSeq,
LOG_PREFIX,
);
SyncLog.normal(`${LOG_PREFIX}: Encryption enabled successfully!`);
} catch (uploadError) {
// CRITICAL: Server data was deleted but new snapshot failed to upload.
// Revert local config to unencrypted state
SyncLog.err(
`${LOG_PREFIX}: Snapshot upload failed after deleting server data!`,
uploadError,
);
// Use providerManager.setProviderConfig() to update both the stored config
// AND the currentProviderPrivateCfg$ observable for proper form state
const revertConfig = {
...existingCfg,
encryptKey: undefined,
isEncryptionEnabled: false,
} as SuperSyncPrivateCfg;
await this._providerManager.setProviderConfig(
SyncProviderId.SuperSync,
revertConfig,
);
// Clear cached adapters since encryption settings were reverted
this._wrappedProviderService.clearCache();
throw new Error(
'CRITICAL: Failed to upload encrypted snapshot after deleting server data. ' +
'Your local data is safe. Encryption has been reverted. Please use "Sync Now" to re-upload your data. ' +
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
);
}
}
}

View file

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

View file

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

View file

@ -10,10 +10,31 @@ import {
EnableEncryptionDialogData,
EnableEncryptionResult,
} from './dialog-enable-encryption/dialog-enable-encryption.component';
import { firstValueFrom } from 'rxjs';
// Module-level reference, set by the service constructor
let dialogOpenerInstance: EncryptionPasswordDialogOpenerService | null = null;
const setInstance = (instance: EncryptionPasswordDialogOpenerService): void => {
dialogOpenerInstance = instance;
};
const callOpener = <T>(fn: (opener: EncryptionPasswordDialogOpenerService) => T): T => {
if (!dialogOpenerInstance) {
throw new Error(
'EncryptionPasswordDialogOpenerService not initialized. ' +
'Ensure the service is injected before calling dialog functions.',
);
}
return fn(dialogOpenerInstance);
};
/**
* Singleton service to open the encryption password change dialog.
* Used by the sync form config which doesn't have direct access to injector.
*
* The constructor self-registers the module-level reference so that
* exported functions work from static form config handlers.
*/
@Injectable({
providedIn: 'root',
@ -21,10 +42,11 @@ import {
export class EncryptionPasswordDialogOpenerService {
private _matDialog = inject(MatDialog);
/**
* Closes all open dialogs. Useful after disabling encryption
* to close the parent settings dialog.
*/
constructor() {
// Self-register so module-level functions can delegate to this instance
setInstance(this);
}
closeAllDialogs(): void {
this._matDialog.closeAll();
}
@ -39,160 +61,42 @@ export class EncryptionPasswordDialogOpenerService {
data: { mode, providerType } as ChangeEncryptionPasswordDialogData,
});
return dialogRef.afterClosed().toPromise();
return firstValueFrom(dialogRef.afterClosed());
}
/**
* Opens the unified change password dialog in disable-only mode.
* @deprecated Use openChangePasswordDialog('disable-only') instead
*/
openDisableEncryptionDialog(
openEnableEncryptionDialog(
providerType: 'supersync' | 'file-based' = 'supersync',
): Promise<ChangeEncryptionPasswordResult | undefined> {
return this.openChangePasswordDialog('disable-only', providerType);
}
/**
* Opens the disable encryption dialog for file-based providers.
*/
openDisableEncryptionDialogForFileBased(): Promise<
ChangeEncryptionPasswordResult | undefined
> {
return this.openChangePasswordDialog('disable-only', 'file-based');
}
openChangePasswordDialogForFileBased(): Promise<
ChangeEncryptionPasswordResult | undefined
> {
return this.openChangePasswordDialog('full', 'file-based');
}
openEnableEncryptionDialog(): Promise<EnableEncryptionResult | undefined> {
): Promise<EnableEncryptionResult | undefined> {
const dialogRef = this._matDialog.open(DialogEnableEncryptionComponent, {
width: '450px',
disableClose: true,
data: { providerType: 'supersync' } as EnableEncryptionDialogData,
data: { providerType } as EnableEncryptionDialogData,
});
return dialogRef.afterClosed().toPromise();
}
openEnableEncryptionDialogForFileBased(): Promise<EnableEncryptionResult | undefined> {
const dialogRef = this._matDialog.open(DialogEnableEncryptionComponent, {
width: '450px',
disableClose: true,
data: { providerType: 'file-based' } as EnableEncryptionDialogData,
});
return dialogRef.afterClosed().toPromise();
return firstValueFrom(dialogRef.afterClosed());
}
}
/**
* Module-level reference to the dialog opener service.
* Initialized by EncryptionPasswordDialogOpenerInitService.
*/
let dialogOpenerInstance: EncryptionPasswordDialogOpenerService | null = null;
/**
* Sets the dialog opener instance. Called during app initialization.
*/
export const setDialogOpenerInstance = (
instance: EncryptionPasswordDialogOpenerService,
): void => {
dialogOpenerInstance = instance;
};
/**
* Opens the encryption password change dialog.
* Can be called from form config onClick handlers.
*/
export const openEncryptionPasswordChangeDialog = (): Promise<
ChangeEncryptionPasswordResult | undefined
> => {
if (!dialogOpenerInstance) {
console.error('EncryptionPasswordDialogOpenerService not initialized');
return Promise.resolve(undefined);
}
return dialogOpenerInstance.openChangePasswordDialog();
};
> => callOpener((o) => o.openChangePasswordDialog());
/**
* Opens the encryption password change dialog for file-based providers.
*/
export const openEncryptionPasswordChangeDialogForFileBased = (): Promise<
ChangeEncryptionPasswordResult | undefined
> => {
if (!dialogOpenerInstance) {
console.error('EncryptionPasswordDialogOpenerService not initialized');
return Promise.resolve(undefined);
}
return dialogOpenerInstance.openChangePasswordDialogForFileBased();
};
> => callOpener((o) => o.openChangePasswordDialog('full', 'file-based'));
/**
* Opens the disable encryption confirmation dialog.
* Can be called from form config onChange handlers.
*/
export const openDisableEncryptionDialog = (): Promise<
ChangeEncryptionPasswordResult | undefined
> => {
if (!dialogOpenerInstance) {
console.error('EncryptionPasswordDialogOpenerService not initialized');
return Promise.resolve(undefined);
}
return dialogOpenerInstance.openDisableEncryptionDialog();
};
/**
* Opens the enable encryption confirmation dialog.
* Can be called from form config onChange handlers.
*/
export const openEnableEncryptionDialog = (): Promise<
EnableEncryptionResult | undefined
> => {
if (!dialogOpenerInstance) {
console.error('EncryptionPasswordDialogOpenerService not initialized');
return Promise.resolve(undefined);
}
return dialogOpenerInstance.openEnableEncryptionDialog();
};
> => callOpener((o) => o.openEnableEncryptionDialog());
/**
* Opens the enable encryption dialog for file-based providers.
*/
export const openEnableEncryptionDialogForFileBased = (): Promise<
EnableEncryptionResult | undefined
> => {
if (!dialogOpenerInstance) {
console.error('EncryptionPasswordDialogOpenerService not initialized');
return Promise.resolve(undefined);
}
return dialogOpenerInstance.openEnableEncryptionDialogForFileBased();
};
> => callOpener((o) => o.openEnableEncryptionDialog('file-based'));
/**
* Opens the disable encryption dialog for file-based providers.
* Can be called from form config onClick handlers.
*/
export const openDisableEncryptionDialogForFileBased = (): Promise<
ChangeEncryptionPasswordResult | undefined
> => {
if (!dialogOpenerInstance) {
console.error('EncryptionPasswordDialogOpenerService not initialized');
return Promise.resolve(undefined);
}
return dialogOpenerInstance.openDisableEncryptionDialogForFileBased();
};
> => callOpener((o) => o.openChangePasswordDialog('disable-only', 'file-based'));
/**
* Closes all open dialogs. Useful after disabling encryption
* to close the parent settings dialog.
*/
export const closeAllDialogs = (): void => {
if (!dialogOpenerInstance) {
console.error('EncryptionPasswordDialogOpenerService not initialized');
return;
}
dialogOpenerInstance.closeAllDialogs();
callOpener((o) => o.closeAllDialogs());
};

View file

@ -38,13 +38,19 @@ export class FileBasedEncryptionService {
await this._applyEncryption(newPassword, 'change');
}
async disableEncryption(): Promise<void> {
await this._applyEncryption(undefined, 'disable');
}
private async _applyEncryption(
encryptKey: string,
action: 'enable' | 'change',
encryptKey: string | undefined,
action: 'enable' | 'change' | 'disable',
): Promise<void> {
SyncLog.normal(`${LOG_PREFIX}: Starting ${action} for file-based provider...`);
if (!encryptKey) {
const isDisable = action === 'disable';
if (!isDisable && !encryptKey) {
throw new Error('Encryption password is required');
}
@ -75,15 +81,15 @@ export class FileBasedEncryptionService {
const existingCfg = await provider.privateCfg.load();
const baseCfg = this._providerManager.getEncryptAndCompressCfg();
const encryptedCfg = {
const adapterCfg = {
...baseCfg,
isEncrypt: true,
isEncrypt: !isDisable,
};
const adapter = this._fileBasedAdapter.createAdapter(
provider,
encryptedCfg,
encryptKey,
adapterCfg,
isDisable ? undefined : encryptKey,
);
const result = await adapter.uploadSnapshot(
@ -92,7 +98,7 @@ export class FileBasedEncryptionService {
'recovery',
vectorClock,
CURRENT_SCHEMA_VERSION,
true,
!isDisable,
uuidv7(),
);
@ -100,15 +106,36 @@ export class FileBasedEncryptionService {
throw new Error(`Snapshot upload failed: ${result.error}`);
}
const newConfig = {
...existingCfg,
encryptKey,
};
await this._providerManager.setProviderConfig(provider.id, newConfig);
this._globalConfigService.updateSection('sync', {
isEncryptionEnabled: true,
});
try {
if (isDisable) {
// Use providerManager.setProviderConfig() instead of direct setPrivateCfg()
// to ensure the currentProviderPrivateCfg$ observable is updated
await this._providerManager.setProviderConfig(provider.id, {
...existingCfg,
encryptKey: undefined,
});
this._globalConfigService.updateSection('sync', {
isEncryptionEnabled: false,
encryptKey: '',
});
} else {
await this._providerManager.setProviderConfig(provider.id, {
...existingCfg,
encryptKey,
});
this._globalConfigService.updateSection('sync', {
isEncryptionEnabled: true,
});
}
} catch (cfgError) {
SyncLog.err(
`${LOG_PREFIX}: Failed to update config after successful upload. ` +
`Server has ${isDisable ? 'unencrypted' : 'encrypted'} data but local config may be stale. ` +
`Please update encryption settings manually.`,
cfgError,
);
throw cfgError;
}
this._derivedKeyCache.clearCache();
this._wrappedProviderService.clearCache();

View file

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

View file

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

View file

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

View file

@ -0,0 +1,190 @@
import { inject, Injectable } from '@angular/core';
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
import { SyncLog } from '../../core/log';
import { SnapshotUploadService } from './snapshot-upload.service';
import { OperationEncryptionService } from '../../op-log/sync/operation-encryption.service';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service';
import { SyncProviderId } from '../../op-log/sync-providers/provider.const';
import { isCryptoSubtleAvailable } from '../../op-log/encryption/encryption';
import { WebCryptoNotAvailableError } from '../../op-log/core/errors/sync-errors';
const LOG_PREFIX = 'SuperSyncEncryptionToggleService';
/**
* Service for enabling/disabling encryption for SuperSync.
*
* Enable flow: encrypt first, then delete, then upload (fail-early on encryption errors)
* Disable flow: delete, then upload unencrypted, then update config
*/
@Injectable({
providedIn: 'root',
})
export class SuperSyncEncryptionToggleService {
private _snapshotUploadService = inject(SnapshotUploadService);
private _encryptionService = inject(OperationEncryptionService);
private _wrappedProviderService = inject(WrappedProviderService);
private _providerManager = inject(SyncProviderManager);
/**
* Enables encryption:
* 1. Encrypt snapshot (fail-early before any destructive action)
* 2. Delete all server data
* 3. Update config BEFORE upload so the upload uses the new key
* 4. Upload encrypted snapshot (reverts config on failure)
*/
async enableEncryption(encryptKey: string): Promise<void> {
SyncLog.normal(`${LOG_PREFIX}: Starting encryption enable...`);
if (!encryptKey) {
throw new Error('Encryption key is required');
}
// Guard against concurrent calls
const activeProvider = this._providerManager.getActiveProvider();
if (activeProvider) {
const currentCfg = (await activeProvider.privateCfg.load()) as
| { isEncryptionEnabled?: boolean; encryptKey?: string }
| undefined;
if (currentCfg?.isEncryptionEnabled && currentCfg?.encryptKey) {
SyncLog.normal(
`${LOG_PREFIX}: Encryption is already enabled, skipping duplicate enableEncryption call`,
);
return;
}
}
// Check crypto availability BEFORE deleting server data
if (!isCryptoSubtleAvailable()) {
throw new WebCryptoNotAvailableError(
'Cannot enable encryption: WebCrypto API is not available. ' +
'Encryption requires a secure context (HTTPS). ' +
'On Android, encryption is not supported.',
);
}
const { syncProvider, existingCfg, state, vectorClock, clientId } =
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
// Encrypt BEFORE deleting server data to fail early if encryption fails
SyncLog.normal(`${LOG_PREFIX}: Encrypting snapshot...`);
const encryptedPayload = await this._encryptionService.encryptPayload(
state,
encryptKey,
);
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
await syncProvider.deleteAllData();
try {
// Update config BEFORE upload so the upload uses the new key
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
const newConfig = {
...existingCfg,
encryptKey,
isEncryptionEnabled: true,
} as SuperSyncPrivateCfg;
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, newConfig);
this._wrappedProviderService.clearCache();
await this._uploadAndFinalize(
syncProvider,
encryptedPayload,
clientId,
vectorClock,
true,
);
SyncLog.normal(`${LOG_PREFIX}: Encryption enabled successfully!`);
} catch (uploadError) {
// Revert config on failure
SyncLog.err(`${LOG_PREFIX}: Failed after deleting server data!`, uploadError);
const revertConfig = {
...existingCfg,
encryptKey: undefined,
isEncryptionEnabled: false,
} as SuperSyncPrivateCfg;
await this._providerManager.setProviderConfig(
SyncProviderId.SuperSync,
revertConfig,
);
this._wrappedProviderService.clearCache();
throw new Error(
'CRITICAL: Failed to upload encrypted snapshot after deleting server data. ' +
'Your local data is safe. Encryption has been reverted. Please use "Sync Now" to re-upload your data. ' +
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
);
}
}
/**
* Disables encryption by deleting all server data and uploading an unencrypted snapshot.
* Config is updated AFTER successful upload for safety.
*/
async disableEncryption(): Promise<void> {
SyncLog.normal(`${LOG_PREFIX}: Starting encryption disable...`);
const { syncProvider, existingCfg, state, vectorClock, clientId } =
await this._snapshotUploadService.gatherSnapshotData(LOG_PREFIX);
SyncLog.normal(`${LOG_PREFIX}: Deleting server data...`);
await syncProvider.deleteAllData();
SyncLog.normal(`${LOG_PREFIX}: Uploading unencrypted snapshot...`);
try {
await this._uploadAndFinalize(syncProvider, state, clientId, vectorClock, false);
// Update config AFTER successful upload
// IMPORTANT: Use providerManager.setProviderConfig() instead of direct setPrivateCfg()
// to ensure the currentProviderPrivateCfg$ observable is updated.
SyncLog.normal(`${LOG_PREFIX}: Updating local config...`);
await this._providerManager.setProviderConfig(SyncProviderId.SuperSync, {
...existingCfg,
encryptKey: undefined,
isEncryptionEnabled: false,
} as SuperSyncPrivateCfg);
this._wrappedProviderService.clearCache();
SyncLog.normal(`${LOG_PREFIX}: Encryption disabled successfully!`);
} catch (uploadError) {
SyncLog.err(
`${LOG_PREFIX}: Snapshot upload failed after deleting server data!`,
uploadError,
);
throw new Error(
'CRITICAL: Failed to upload unencrypted snapshot after deleting server data. ' +
'Your local data is safe. Encryption is still enabled. Please try disabling encryption again. ' +
`Original error: ${uploadError instanceof Error ? uploadError.message : uploadError}`,
);
}
}
private async _uploadAndFinalize(
syncProvider: Parameters<SnapshotUploadService['uploadSnapshot']>[0],
payload: unknown,
clientId: string,
vectorClock: Record<string, number>,
isPayloadEncrypted: boolean,
): Promise<void> {
const result = await this._snapshotUploadService.uploadSnapshot(
syncProvider,
payload,
clientId,
vectorClock,
isPayloadEncrypted,
);
if (!result.accepted) {
throw new Error(`Snapshot upload failed: ${result.error}`);
}
await this._snapshotUploadService.updateLastServerSeq(
syncProvider,
result.serverSeq,
LOG_PREFIX,
);
}
}

View file

@ -14,6 +14,7 @@ import { SyncLog } from '../../core/log';
import { DerivedKeyCacheService } from '../../op-log/encryption/derived-key-cache.service';
import { SuperSyncPrivateCfg } from '../../op-log/sync-providers/super-sync/super-sync.model';
import { WrappedProviderService } from '../../op-log/sync-providers/wrapped-provider.service';
import { SyncWrapperService } from './sync-wrapper.service';
// Maps sync providers to their corresponding form field in SyncConfig
// Dropbox is null because it doesn't store settings in the form (uses OAuth)
@ -92,6 +93,7 @@ export class SyncConfigService {
private _globalConfigService = inject(GlobalConfigService);
private _derivedKeyCache = inject(DerivedKeyCacheService);
private _wrappedProvider = inject(WrappedProviderService);
private _syncWrapper = inject(SyncWrapperService);
private _lastSettings: SyncConfig | null = null;
@ -161,7 +163,12 @@ export class SyncConfigService {
if (!currentProviderCfg) {
return from(
fetch('/assets/sync-config-default-override.json')
.then((res) => res.json())
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.json();
})
.then((defaultOverride) => {
return {
...baseConfig,
@ -212,8 +219,11 @@ export class SyncConfigService {
return of(result);
}),
// Redact sensitive fields (passwords, encryption keys) in all environments
tap((v) => SyncLog.log('syncSettingsForm$', redactSensitiveFields(v))),
// Keep _lastSettings in sync so Formly modelChange doesn't trigger redundant saves
tap((v) => {
this._lastSettings = v;
SyncLog.log('syncSettingsForm$', redactSensitiveFields(v));
}),
);
async updateEncryptionPassword(
@ -246,13 +256,12 @@ export class SyncConfigService {
await this._providerManager.setProviderConfig(activeProvider.id, newConfig);
// Ensure global config reflects encryption enabled when password is entered
this._globalConfigService.updateSection('sync', { isEncryptionEnabled: true });
// Clear cached encryption keys to force re-derivation with new password
this._derivedKeyCache.clearCache();
// Clear cached adapters to force recreation with new encryption settings
this._wrappedProvider.clearCache();
// Allow encryption dialogs to appear again after password change
this._syncWrapper.clearEncryptionDialogSuppression();
}
async updateSettingsFromForm(newSettings: SyncConfig, isForce = false): Promise<void> {
@ -331,7 +340,7 @@ export class SyncConfigService {
{} as Record<string, unknown>,
);
// The provider's saved config is the source of truth after EncryptionDisableService runs
// The provider's saved config is the source of truth after SuperSyncEncryptionToggleService runs
// oldConfig is loaded from activeProvider.privateCfg.load()
// When encryption is explicitly disabled, we must clear encryptKey regardless of form state
const isEncryptionDisabledInSavedConfig =

View file

@ -48,18 +48,20 @@ import { DialogGetAndEnterAuthCodeComponent } from './dialog-get-and-enter-auth-
import { DialogConflictResolutionResult } from './sync.model';
import { DialogSyncConflictComponent } from './dialog-sync-conflict/dialog-sync-conflict.component';
import { ReminderService } from '../../features/reminder/reminder.service';
import { DataInitService } from '../../core/data-init/data-init.service';
import { DialogSyncInitialCfgComponent } from './dialog-sync-initial-cfg/dialog-sync-initial-cfg.component';
import { DialogIncompleteSyncComponent } from './dialog-incomplete-sync/dialog-incomplete-sync.component';
import { DialogHandleDecryptErrorComponent } from './dialog-handle-decrypt-error/dialog-handle-decrypt-error.component';
import { DialogEnterEncryptionPasswordComponent } from './dialog-enter-encryption-password/dialog-enter-encryption-password.component';
import { DialogIncoherentTimestampsErrorComponent } from './dialog-incoherent-timestamps-error/dialog-incoherent-timestamps-error.component';
import {
DialogSyncErrorComponent,
DialogSyncErrorResult,
} from './dialog-sync-error/dialog-sync-error.component';
import { SyncLog } from '../../core/log';
import { promiseTimeout } from '../../util/promise-timeout';
import { devError } from '../../util/dev-error';
import { alertDialog, confirmDialog } from '../../util/native-dialogs';
import { UserInputWaitStateService } from './user-input-wait-state.service';
import { SYNC_WAIT_TIMEOUT_MS, SYNC_REINIT_DELAY_MS } from './sync.const';
import { SYNC_WAIT_TIMEOUT_MS } from './sync.const';
import { SuperSyncStatusService } from '../../op-log/sync/super-sync-status.service';
import { IS_ELECTRON } from '../../app.constants';
import { OperationLogStoreService } from '../../op-log/persistence/operation-log-store.service';
@ -76,7 +78,7 @@ export class SyncWrapperService {
private _translateService = inject(TranslateService);
private _snackService = inject(SnackService);
private _matDialog = inject(MatDialog);
private _dataInitService = inject(DataInitService);
private _reminderService = inject(ReminderService);
private _userInputWaitState = inject(UserInputWaitStateService);
private _superSyncStatusService = inject(SuperSyncStatusService);
@ -115,6 +117,13 @@ export class SyncWrapperService {
*/
private _isEncryptionOperationInProgress$ = new BehaviorSubject(false);
/**
* When true, encryption-related dialogs (missing password, decrypt error) are suppressed.
* Set after the user cancels a dialog so they can navigate to settings to change the password
* without being blocked by recurring auto-sync dialogs. Cleared when encryption config changes.
*/
private _suppressEncryptionDialogs = false;
/**
* Observable for UI: true when all local changes have been uploaded.
* Used for all sync providers to show the single checkmark indicator.
@ -144,6 +153,14 @@ export class SyncWrapperService {
return this._isEncryptionOperationInProgress$.getValue();
}
/**
* Clears the suppression flag so encryption dialogs can appear again.
* Called after the user changes the encryption password via settings.
*/
clearEncryptionDialogSuppression(): void {
this._suppressEncryptionDialogs = false;
}
// Expose shared user input wait state for other services (e.g., SyncTriggerService)
isWaitingForUserInput$ = this._userInputWaitState.isWaitingForUserInput$;
@ -190,7 +207,7 @@ export class SyncWrapperService {
this._isSyncInProgress$.next(true);
// Set SYNCING status so ImmediateUploadService knows not to interfere
this._providerManager.setSyncStatus('SYNCING');
return this._sync().finally(() => {
const result = await this._sync().finally(() => {
this._isSyncInProgress$.next(false);
// Safeguard: if _sync() threw or completed without setting a final status,
// reset from SYNCING to UNKNOWN_OR_CHANGED to avoid getting stuck in SYNCING state
@ -198,6 +215,16 @@ export class SyncWrapperService {
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
}
});
// After successful sync, prompt for encryption if SuperSync is active without it.
// This ensures data is downloaded and merged first, preventing data loss.
if (result === SyncStatus.InSync) {
this._promptSuperSyncEncryptionIfNeeded().catch((err) => {
SyncLog.err('Error prompting for encryption:', err);
});
}
return result;
}
/**
@ -229,7 +256,9 @@ export class SyncWrapperService {
}),
]);
} catch (e) {
SyncLog.warn('Timeout waiting for sync to complete, proceeding anyway');
throw new Error(
'Cannot change encryption settings: sync timed out. Please try again.',
);
}
}
@ -288,7 +317,7 @@ export class SyncWrapperService {
if (downloadResult.cancelled) {
SyncLog.log('SyncWrapperService: Sync cancelled by user. Skipping upload phase.');
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return SyncStatus.NotConfigured;
return 'HANDLED_ERROR';
}
// 2. Upload pending local ops
@ -300,6 +329,15 @@ export class SyncWrapperService {
);
}
// If upload was cancelled (piggybacked SYNC_IMPORT conflict dialog), skip LWW re-upload
if (uploadResult?.cancelled) {
SyncLog.log(
'SyncWrapperService: Upload cancelled by user (piggybacked SYNC_IMPORT). Skipping LWW re-upload.',
);
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
return 'HANDLED_ERROR';
}
// 3. If LWW created local-win ops, upload them (with retry limit to prevent infinite loops)
let lwwRetries = 0;
let pendingLwwOps =
@ -528,6 +566,36 @@ export class SyncWrapperService {
});
}
private async _forceDownload(): Promise<void> {
SyncLog.log('SyncWrapperService: forceDownload called - downloading remote state');
await this.runWithSyncBlocked(async () => {
try {
const rawProvider = this._providerManager.getActiveProvider();
const syncCapableProvider =
await this._wrappedProvider.getOperationSyncCapable(rawProvider);
if (!syncCapableProvider) {
SyncLog.warn(
'SyncWrapperService: Cannot force download - provider not available',
);
return;
}
await this._opLogSyncService.forceDownloadRemoteState(syncCapableProvider);
this._providerManager.setSyncStatus('IN_SYNC');
SyncLog.log('SyncWrapperService: Force download complete');
} catch (error) {
SyncLog.err('SyncWrapperService: Force download failed:', error);
const errStr = getSyncErrorStr(error);
this._snackService.open({
msg: errStr,
type: 'ERROR',
});
}
});
}
async configuredAuthForSyncProviderIfNecessary(
providerId: SyncProviderId,
force = false,
@ -596,52 +664,33 @@ export class SyncWrapperService {
* Uses fire-and-forget pattern but logs errors instead of swallowing them.
*/
private _handleIncoherentTimestampsDialog(): void {
const dialogRef = this._matDialog.open(DialogIncoherentTimestampsErrorComponent, {
this._openSyncErrorDialog({ type: 'incoherent-timestamps' });
}
private _handleIncompleteSyncDialog(modelId: string | undefined): void {
this._openSyncErrorDialog({ type: 'incomplete-sync', modelId });
}
private _openSyncErrorDialog(data: {
type: 'incomplete-sync' | 'incoherent-timestamps';
modelId?: string;
}): void {
const dialogRef = this._matDialog.open(DialogSyncErrorComponent, {
data,
disableClose: true,
autoFocus: false,
});
// Use firstValueFrom for proper async handling
firstValueFrom(dialogRef.afterClosed())
.then(async (res) => {
.then(async (res: DialogSyncErrorResult) => {
if (res === 'FORCE_UPDATE_REMOTE') {
await this.forceUpload();
} else if (res === 'FORCE_UPDATE_LOCAL') {
// Op-log architecture handles this differently
SyncLog.log(
'SyncWrapperService: forceDownload called (delegated to op-log sync)',
);
await this._forceDownload();
}
})
.catch((err) => {
SyncLog.err('Error handling incoherent timestamps dialog result:', err);
this._snackService.open({
type: 'ERROR',
msg: T.F.SYNC.S.DIALOG_RESULT_ERROR,
});
});
}
/**
* Handle incomplete sync dialog with proper async error handling.
* Uses fire-and-forget pattern but logs errors instead of swallowing them.
*/
private _handleIncompleteSyncDialog(modelId: string | undefined): void {
const dialogRef = this._matDialog.open(DialogIncompleteSyncComponent, {
data: { modelId },
disableClose: true,
autoFocus: false,
});
// Use firstValueFrom for proper async handling
firstValueFrom(dialogRef.afterClosed())
.then(async (res) => {
if (res === 'FORCE_UPDATE_REMOTE') {
await this.forceUpload();
}
})
.catch((err) => {
SyncLog.err('Error handling incomplete sync dialog result:', err);
SyncLog.err('Error handling sync error dialog result:', err);
this._snackService.open({
type: 'ERROR',
msg: T.F.SYNC.S.DIALOG_RESULT_ERROR,
@ -674,6 +723,12 @@ export class SyncWrapperService {
* Opens a simple dialog to prompt for the password, then re-syncs.
*/
private _handleMissingPasswordDialog(): void {
// Suppress dialog if user previously cancelled (so they can navigate to settings)
if (this._suppressEncryptionDialogs) {
this._providerManager.setSyncStatus('ERROR');
return;
}
// Prevent multiple password dialogs from opening simultaneously
if (this._passwordDialog) {
return;
@ -689,23 +744,37 @@ export class SyncWrapperService {
autoFocus: false,
});
this._passwordDialog.afterClosed().subscribe((result) => {
this._passwordDialog = undefined;
firstValueFrom(this._passwordDialog.afterClosed())
.then((result) => {
this._passwordDialog = undefined;
if (result?.password) {
// Password was entered and saved, re-sync
this.sync();
} else if (result?.forceOverwrite) {
// Force overwrite succeeded; reflect synced status
this._providerManager.setSyncStatus('IN_SYNC');
} else {
// User cancelled - set status to unknown
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
}
});
if (result?.password) {
// Password was entered and saved — clear suppression and re-sync
this._suppressEncryptionDialogs = false;
this.sync();
} else if (result?.forceOverwrite) {
// Force overwrite succeeded; reflect synced status
this._suppressEncryptionDialogs = false;
this._providerManager.setSyncStatus('IN_SYNC');
} else {
// User cancelled — suppress future dialogs so they can navigate to settings
this._suppressEncryptionDialogs = true;
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
}
})
.catch((err) => {
this._passwordDialog = undefined;
SyncLog.err('Error handling missing password dialog result:', err);
});
}
private _handleDecryptionError(): void {
// Suppress dialog if user previously cancelled (so they can navigate to settings)
if (this._suppressEncryptionDialogs) {
this._providerManager.setSyncStatus('ERROR');
return;
}
// Prevent multiple password dialogs from opening simultaneously
if (this._passwordDialog) {
return;
@ -727,26 +796,31 @@ export class SyncWrapperService {
autoFocus: false,
});
this._passwordDialog.afterClosed().subscribe(({ isReSync, isForceUpload }) => {
this._passwordDialog = undefined;
firstValueFrom(this._passwordDialog.afterClosed())
.then((result) => {
this._passwordDialog = undefined;
if (isReSync) {
this.sync();
}
if (isForceUpload) {
this.forceUpload();
}
// Reset status if user cancelled without taking action
if (!isReSync && !isForceUpload) {
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
}
});
if (result?.isReSync) {
this._suppressEncryptionDialogs = false;
this.sync();
} else if (result?.isForceUpload) {
this._suppressEncryptionDialogs = false;
this.forceUpload();
} else {
// User cancelled — suppress future dialogs so they can navigate to settings
this._suppressEncryptionDialogs = true;
this._providerManager.setSyncStatus('UNKNOWN_OR_CHANGED');
}
})
.catch((err) => {
this._passwordDialog = undefined;
SyncLog.err('Error handling decrypt error dialog result:', err);
});
}
/**
* Handles LocalDataConflictError by showing a conflict resolution dialog.
* This occurs when file-based sync (Dropbox, WebDAV) detects local unsynced
* changes that would be lost if the remote snapshot is applied.
* This occurs when sync detects local data that would be overwritten by remote data.
*
* User can choose:
* - USE_LOCAL: Upload local data, overwriting remote (uses forceUploadLocalState)
@ -846,27 +920,6 @@ export class SyncWrapperService {
}
}
private async _reInitAppAfterDataModelChange(
downloadedMainModelData?: Record<string, unknown>,
): Promise<void> {
SyncLog.log('Starting data re-initialization after sync...');
try {
await Promise.all([
// Use reInitFromRemoteSync() which now uses the passed downloaded data
// instead of reading from IndexedDB (entity models aren't stored there)
this._dataInitService.reInitFromRemoteSync(downloadedMainModelData),
]);
// wait an extra frame to potentially avoid follow up problems
await promiseTimeout(SYNC_REINIT_DELAY_MS);
SyncLog.log('Data re-initialization complete');
// Signal that data reload is complete
} catch (error) {
SyncLog.err('Error during data re-initialization:', error);
throw error;
}
}
private _c(str: string): boolean {
return confirmDialog(this._translateService.instant(str));
}
@ -895,13 +948,121 @@ export class SyncWrapperService {
return T.F.SYNC.S.ERROR_PERMISSION;
}
private lastConflictDialog?: MatDialogRef<any, any>;
private lastConflictDialog?: MatDialogRef<
DialogSyncConflictComponent,
DialogConflictResolutionResult
>;
/**
* Reference to any open password-related dialog (enter password or decrypt error).
* Used to prevent multiple simultaneous password dialogs from opening.
* Uses Record<string, unknown> because dialog components are dynamically imported.
*/
private _passwordDialog?: MatDialogRef<any, any>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private _passwordDialog?: MatDialogRef<any, Record<string, unknown>>;
/**
* Reference to the encryption-required dialog to prevent multiple opens.
* Uses Record<string, unknown> because dialog component is dynamically imported.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private _encryptionRequiredDialog?: MatDialogRef<any, Record<string, unknown>>;
/**
* Synchronous guard to prevent TOCTOU race in _promptSuperSyncEncryptionIfNeeded.
* The async import() between checking _encryptionRequiredDialog and opening the dialog
* creates a window where concurrent calls can both pass the guard.
*/
private _isOpeningEncryptionDialog = false;
/**
* After a successful sync, checks if SuperSync is active without encryption.
* If so, opens the encryption dialog. Data has already been synced, so no data loss.
*/
private async _promptSuperSyncEncryptionIfNeeded(): Promise<void> {
SyncLog.log(
'_promptSuperSyncEncryptionIfNeeded called, _isOpeningEncryptionDialog=',
this._isOpeningEncryptionDialog,
', _encryptionRequiredDialog=',
!!this._encryptionRequiredDialog,
', openDialogs=',
this._matDialog.openDialogs.length,
);
const providerId = await firstValueFrom(this.syncProviderId$);
if (providerId !== SyncProviderId.SuperSync) {
return;
}
const provider = this._providerManager.getActiveProvider();
if (!provider) {
return;
}
const cfg = (await provider.privateCfg.load()) as
| { isEncryptionEnabled?: boolean; encryptKey?: string }
| undefined;
if (cfg?.isEncryptionEnabled && cfg?.encryptKey) {
SyncLog.log('SuperSync encryption already enabled, skipping');
return;
}
SyncLog.log(
'SuperSync encryption not enabled — prompting user, openDialogs=',
this._matDialog.openDialogs.length,
', _isOpeningEncryptionDialog=',
this._isOpeningEncryptionDialog,
', _encryptionRequiredDialog=',
!!this._encryptionRequiredDialog,
);
// Don't open if ANY dialog is already open. The config dialog's save() method
// handles encryption setup (either "enable encryption" or "enter password" based
// on a server probe). Opening a competing dialog causes duplicate encryption
// prompts and can trigger unwanted clean-slate operations.
if (this._matDialog.openDialogs.length > 0) {
SyncLog.log('Dialog already open — skipping encryption prompt');
return;
}
if (!this._encryptionRequiredDialog && !this._isOpeningEncryptionDialog) {
this._isOpeningEncryptionDialog = true;
SyncLog.log('Opening encryption dialog (guard passed)');
try {
const { DialogEnableEncryptionComponent } =
await import('./dialog-enable-encryption/dialog-enable-encryption.component');
// Double-check after async import: another call might have opened a dialog
if (this._encryptionRequiredDialog || this._matDialog.openDialogs.length > 0) {
SyncLog.log('Dialog appeared during import — aborting');
return;
}
this._encryptionRequiredDialog = this._matDialog.open(
DialogEnableEncryptionComponent,
{
disableClose: true,
data: { providerType: 'supersync', initialSetup: true },
},
);
this._encryptionRequiredDialog.afterClosed().subscribe((result) => {
this._encryptionRequiredDialog = undefined;
if (result?.success) {
this.sync();
}
});
} finally {
this._isOpeningEncryptionDialog = false;
}
} else {
SyncLog.log(
'Skipping encryption dialog — guard blocked: _encryptionRequiredDialog=',
!!this._encryptionRequiredDialog,
', _isOpeningEncryptionDialog=',
this._isOpeningEncryptionDialog,
);
}
}
private _openConflictDialog$(
conflictData: ConflictData,
@ -915,7 +1076,10 @@ export class SyncWrapperService {
disableClose: true,
data: conflictData,
});
return this.lastConflictDialog.afterClosed();
// disableClose: true ensures the dialog always closes with a result
return this.lastConflictDialog
.afterClosed()
.pipe(filter((r): r is DialogConflictResolutionResult => r !== undefined));
}
/**

View file

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

View file

@ -0,0 +1,134 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { T } from '../../t.const';
export type PasswordStrengthLevel = 'weak' | 'fair' | 'strong';
@Component({
selector: 'password-strength',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (password()) {
<div class="strength-bar">
<div
class="strength-fill"
[class.weak]="level() === 'weak'"
[class.fair]="level() === 'fair'"
[class.strong]="level() === 'strong'"
[style.width.%]="fillPercent()"
></div>
</div>
<span
class="strength-label"
[class.weak]="level() === 'weak'"
[class.fair]="level() === 'fair'"
[class.strong]="level() === 'strong'"
>{{ label() }}</span
>
}
`,
styles: [
`
:host {
display: flex;
align-items: center;
gap: 8px;
margin-top: -8px;
margin-bottom: 8px;
}
.strength-bar {
flex: 1;
height: 4px;
background: rgba(128, 128, 128, 0.2);
border-radius: 2px;
overflow: hidden;
}
.strength-fill {
height: 100%;
border-radius: 2px;
transition: width 0.3s ease;
&.weak {
background: var(--c-warn);
}
&.fair {
background: var(--c-accent);
}
&.strong {
background: var(--palette-primary-500);
}
}
.strength-label {
font-size: 12px;
white-space: nowrap;
&.weak {
color: var(--c-warn);
}
&.fair {
color: var(--c-accent);
}
&.strong {
color: var(--palette-primary-500);
}
}
`,
],
})
export class PasswordStrengthComponent {
private _translateService = inject(TranslateService);
password = input<string>('');
minLength = input<number>(8);
level = computed<PasswordStrengthLevel>(() => {
const pwd = this.password();
if (!pwd || pwd.length < this.minLength()) {
return 'weak';
}
let score = 0;
if (pwd.length >= 12) score++;
if (pwd.length >= 16) score++;
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++;
if (/\d/.test(pwd)) score++;
if (/[^a-zA-Z0-9]/.test(pwd)) score++;
if (score >= 4) return 'strong';
if (score >= 2) return 'fair';
return 'weak';
});
fillPercent = computed(() => {
switch (this.level()) {
case 'weak':
return 33;
case 'fair':
return 66;
case 'strong':
return 100;
}
});
label = computed(() => {
switch (this.level()) {
case 'weak':
return this._translateService.instant(T.G.PASSWORD_STRENGTH_WEAK);
case 'fair':
return this._translateService.instant(T.G.PASSWORD_STRENGTH_FAIR);
case 'strong':
return this._translateService.instant(T.G.PASSWORD_STRENGTH_STRONG);
}
});
}

View file

@ -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 dont know the old password, you can overwrite the server with your local data and a new password.",
"FORCE_OVERWRITE_INFO": "If you prefer to keep your local data, you can overwrite the server instead.",
"FORCE_OVERWRITE_TITLE": "Overwrite Server Data?",
"FORCE_OVERWRITE_CONFIRM": "This will <strong>delete all data on the server</strong> and upload your local data with the new password. Unsynced local changes may be lost. Continue?",
"BTN_FORCE_OVERWRITE": "Force Overwrite Server"
"FORCE_OVERWRITE_CONFIRM": "This will <strong>delete all data on the server</strong> and upload your local data encrypted with this password. Continue?",
"BTN_FORCE_OVERWRITE": "Use Local Data"
},
"D_FRESH_CLIENT_CONFIRM": {
"MESSAGE": "This appears to be a fresh installation. Remote data with {{count}} changes was found. Do you want to download and apply this data?",
"MESSAGE": "This appears to be a fresh installation. Remote data with {{count}} changes was found. Do you want to download and overwrite your local data with it?",
"OK": "Download Remote Data",
"TITLE": "Initial Sync"
},
"D_SYNC_IMPORT_CONFLICT": {
"TITLE": "Sync Conflict Detected",
"MESSAGE": "Your local data was imported/restored, but the server has {{filteredOpCount}} changes from other devices.",
"EXPLANATION": "These changes were made without knowledge of your import and cannot be automatically merged.",
"MSG_INCOMING": "Your data was fully replaced on another device. You have {{filteredOpCount}} local changes that may not carry over.",
"MSG_INCOMING_NO_OPS": "Your data was fully replaced on another device. This device will sync to the new state.",
"MSG_LOCAL_FILTERS": "Your local data was imported/restored, but the server has {{filteredOpCount}} changes from other devices that cannot be automatically merged.",
"IMPORT_DATE": "Import date",
"FILTERED_CHANGES": "Filtered changes",
"FILTERED_CHANGES": "Affected changes",
"USE_LOCAL": "Use My Data",
"USE_REMOTE": "Use Server Data"
"USE_REMOTE": "Use Server Data",
"RECOMMEND_SERVER": "Recommended: Accept the server data to stay in sync with the other device.",
"REASON_PASSWORD_CHANGED": "Reason: The encryption password was changed on another device.",
"REASON_FILE_IMPORT": "Reason: A data file was imported on another device.",
"REASON_BACKUP_RESTORE": "Reason: A backup was restored on another device.",
"REASON_FORCE_UPLOAD": "Reason: Another device force-uploaded its local data.",
"REASON_SERVER_MIGRATION": "Reason: Data was migrated to a new sync server.",
"REASON_REPAIR": "Reason: An automatic data repair was performed.",
"REASON_UNKNOWN": "Reason: A full state replacement was performed."
},
"D_LOCAL_DATA_REPLACE_CONFIRM": {
"MESSAGE": "You have {{count}} unsynced local changes that will be replaced by remote data. Do you want to continue?",
@ -1035,11 +1044,12 @@
"D_INCOMPLETE_SYNC": {
"BTN_CLOSE_APP": "Close App",
"BTN_DOWNLOAD_BACKUP": "Download Local Backup",
"BTN_FORCE_DOWNLOAD": "Force Download Remote",
"BTN_FORCE_UPLOAD": "Force Upload Local",
"INCOHERENT_P1": "The date of your remote data is from the future",
"INCOHERENT_P2": "You should check the time configured on your systems!",
"P1": "The remote sync data is incoherent!",
"P2": "Affected model:",
"T1": "Last sync was incomplete!",
"T2": "Your archive data was not properly uploaded during last sync:",
"T3": "You have 2 Options:",
"T4": "1. Go to your other device and complete the sync there.",
"T5": "2. Or you can overwrite the remote data with your local one. All remote changes will\n be lost!",
@ -1137,7 +1147,13 @@
"ENABLE_ENCRYPTION_SUCCESS": "Encryption enabled successfully",
"ENABLE_ENCRYPTION_NOT_READY": "Sync is not enabled or configured.",
"ENABLE_ENCRYPTION_SUPERSYNC_ONLY": "This feature is only available for SuperSync.",
"ENABLE_ENCRYPTION_PASSWORD_REQUIRED": "Please enter an encryption password first."
"ENABLE_ENCRYPTION_PASSWORD_REQUIRED": "Please enter an encryption password first.",
"ENCRYPTION_ENCOURAGED": "Encryption is required for SuperSync. Please enable encryption to protect your data.",
"ENCRYPTION_SETUP_INTRO": "<strong>SuperSync password-encrypts your data before it leaves your device</strong>, making misuse or leaks of your private data almost impossible.",
"SETUP_ENCRYPTION_TITLE": "SuperSync: Set Password",
"SETUP_ENCRYPTION_PASSWORD_WARNING": "Use the same password on all devices. A forgotten password can be reset by overwriting remote data.",
"SETUP_ENCRYPTION_BTN": "Set Password",
"SETUP_DISABLE_SUPERSYNC_BTN": "Disable SuperSync"
},
"IMPORT_ENCRYPTION_WARNING": {
"TITLE": "Encryption Settings Will Change",
@ -1245,6 +1261,7 @@
"DECRYPTION_FAILED": "Failed to decrypt synced data. Please check your encryption password.",
"ENCRYPTION_PASSWORD_REQUIRED": "Encrypted data received but no encryption password is configured. Please set your encryption password in sync settings.",
"ENCRYPTION_DISABLED_ON_OTHER_DEVICE": "Encryption was disabled on another device. Your sync settings have been updated.",
"ENCRYPTION_REQUIRED_FOR_SUPERSYNC": "Encryption is required for SuperSync. Please set an encryption password to continue syncing.",
"WEB_CRYPTO_NOT_AVAILABLE": "Encryption is not available on this device. On Android, encryption requires a secure context (HTTPS) which is not available. Please disable encryption in sync settings or sync from a desktop web browser.",
"PERSIST_FAILED": "Failed to save changes. State may be inconsistent - please reload.",
"DEFERRED_ACTION_FAILED": "Some changes made during sync could not be saved. Please reload to recover.",
@ -1274,7 +1291,11 @@
"FINISH_DAY_SYNC_ERROR": "Sync error while finishing day. Please try again.",
"SERVER_MIGRATION_VALIDATION_FAILED": "Could not migrate data to server - local data validation failed. Please try importing a backup.",
"MIGRATION_FAILED": "Some sync data could not be migrated and was skipped. This may indicate data corruption or a bug.",
"VECTOR_CLOCK_LIMIT_REACHED": "Sync has tracked {{originalSize}} devices/browsers, exceeding the limit of {{maxSize}}. Inactive device entries were pruned. Consider a sync reset if you experience issues."
"VECTOR_CLOCK_LIMIT_REACHED": "Sync has tracked {{originalSize}} devices/browsers, exceeding the limit of {{maxSize}}. Inactive device entries were pruned. Consider a sync reset if you experience issues.",
"ENABLE_ENCRYPTION_FAILED": "Failed to enable encryption: {{message}}",
"DISABLE_ENCRYPTION_FAILED": "Failed to disable encryption: {{message}}",
"CHANGE_PASSWORD_FAILED": "Failed to change password: {{message}}",
"OVERWRITE_SERVER_FAILED": "Failed to overwrite server data: {{message}}"
},
"D_DATA_REPAIRED": {
"TITLE": "Data Repair Executed",
@ -1833,11 +1854,16 @@
"NONE": "None",
"OK": "Ok",
"OVERDUE": "Overdue",
"PASSWORD_STRENGTH_FAIR": "Fair",
"PASSWORD_STRENGTH_STRONG": "Strong",
"PASSWORD_STRENGTH_WEAK": "Weak",
"PROCESSING": "Processing",
"REMOVE": "Remove",
"SAVE": "Save",
"SHARE": "Share",
"SUBMIT": "Submit",
"TITLE": "Title",
"TOGGLE_PASSWORD_VISIBILITY": "Toggle password visibility",
"TODAY_TAG_TITLE": "Today",
"UNDO": "Undo",
"WITHOUT_PROJECT": "Without Project",

View file

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