diff --git a/e2e/pages/supersync.page.ts b/e2e/pages/supersync.page.ts index 52a60276d8..a28d70538e 100644 --- a/e2e/pages/supersync.page.ts +++ b/e2e/pages/supersync.page.ts @@ -331,7 +331,7 @@ export class SuperSyncPage extends BasePage { await this.page.waitForTimeout(1000); // Define locators for dialogs we might see - const configDialog = this.page.locator('dialog-sync-initial-cfg'); + const configDialog = this.page.locator('dialog-sync-cfg'); const passwordDialog = this.page.locator('dialog-enter-encryption-password'); const enableEncryptionDialog = this.page.locator('dialog-enable-encryption'); @@ -903,7 +903,7 @@ export class SuperSyncPage extends BasePage { } // Client B: legacy password dialog (dialog-enter-encryption-password vs - // dialog-sync-initial-cfg password prompt) + // dialog-sync-cfg password prompt) const legacyPwVisible = await passwordDialog.isVisible().catch(() => false); if (legacyPwVisible) { console.log('[SuperSyncPage] Password dialog — entering password'); diff --git a/src/app/core-ui/main-header/main-header.component.ts b/src/app/core-ui/main-header/main-header.component.ts index 2c78b835e3..1cbd4473b1 100644 --- a/src/app/core-ui/main-header/main-header.component.ts +++ b/src/app/core-ui/main-header/main-header.component.ts @@ -222,9 +222,9 @@ export class MainHeaderComponent implements OnDestroy { if (this.dialogSyncCfgRef) { return; } - const { DialogSyncInitialCfgComponent } = - await import('../../imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component'); - this.dialogSyncCfgRef = this.matDialog.open(DialogSyncInitialCfgComponent); + const { DialogSyncCfgComponent } = + await import('../../imex/sync/dialog-sync-cfg/dialog-sync-cfg.component'); + this.dialogSyncCfgRef = this.matDialog.open(DialogSyncCfgComponent); this._subs.add( this.dialogSyncCfgRef.afterClosed().subscribe(() => { this.dialogSyncCfgRef = null; diff --git a/src/app/features/config/form-cfgs/sync-form.const.ts b/src/app/features/config/form-cfgs/sync-form.const.ts index 6010db703c..402c0eaee7 100644 --- a/src/app/features/config/form-cfgs/sync-form.const.ts +++ b/src/app/features/config/form-cfgs/sync-form.const.ts @@ -8,7 +8,16 @@ import { loadSyncProviders, LocalFileSyncPicker, } from '../../../op-log/sync-providers/sync-providers.factory'; -import { FormlyFieldConfig } from '@ngx-formly/core'; +import { FormlyFieldConfig, FormlyFieldProps } from '@ngx-formly/core'; + +/** + * Stable structural marker on the "Advanced" collapsibles so the dialog + * component can route action-button injection without depending on the + * (globally shared) translation key in `props.label`. + */ +export interface SyncCollapsibleProps extends FormlyFieldProps { + syncRole?: 'advanced'; +} 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 { @@ -168,6 +177,7 @@ export const SYNC_FORM: ConfigFormSection = { key: 'syncFolderPath', templateOptions: { text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH, + btnStyle: 'stroked', onClick: async () => { const providers = await loadSyncProviders(); const localProvider = providers.find( @@ -203,6 +213,7 @@ export const SYNC_FORM: ConfigFormSection = { key: 'safFolderUri', templateOptions: { text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH, + btnStyle: 'stroked', onClick: async () => { // NOTE: this actually sets the value in the model const providers = await loadSyncProviders(); @@ -309,8 +320,6 @@ export const SYNC_FORM: ConfigFormSection = { hideExpression: (m, v, field) => field?.parent?.model.syncProvider !== SyncProviderId.Dropbox, resetOnHide: false, - // Custom marker for identifying this field group in config-page.component.ts - props: { dropboxAuth: true } as any, fieldGroup: [ { type: 'tpl', @@ -319,49 +328,9 @@ export const SYNC_FORM: ConfigFormSection = { text: T.F.SYNC.FORM.DROPBOX.INFO_TEXT, }, }, - { - type: 'tpl', - key: 'authStatus', - className: 'auth-status-indicator', - templateOptions: { - tag: 'p', - // Text will be set dynamically in config-page.component.ts - text: '', - }, - }, - // Authentication button will be added programmatically in config-page.component.ts ], }, - { - key: 'syncInterval', - type: 'duration', - // NOTE: we don't hide because model updates don't seem to work properly for this - // hideExpression: ((model: DropboxSyncConfig) => !model.accessToken), - // Hide for SuperSync (uses fixed interval) and when manual sync only is enabled - hideExpression: (m, v, field) => - field?.parent?.model.syncProvider === SyncProviderId.SuperSync || - field?.parent?.model.isManualSyncOnly === true, - resetOnHide: true, - templateOptions: { - required: true, - isAllowSeconds: true, - label: T.F.SYNC.FORM.L_SYNC_INTERVAL, - description: T.G.DURATION_DESCRIPTION, - }, - }, - { - key: 'isManualSyncOnly', - type: 'checkbox', - // Only show for file-based providers (Dropbox, WebDAV, LocalFile) - hideExpression: (m, v, field) => - field?.parent?.model.syncProvider === SyncProviderId.SuperSync || - field?.parent?.model.syncProvider === null, - templateOptions: { - label: T.F.SYNC.FORM.L_MANUAL_SYNC_ONLY, - }, - }, - // Encryption status box - shown when encryption is enabled (for any provider) { hideExpression: (m: any, v: any, field?: FormlyFieldConfig) => @@ -423,13 +392,40 @@ export const SYNC_FORM: ConfigFormSection = { }, // COMMON SETTINGS - // Hide for SuperSync - uses fixed settings (no compression config, encryption handled separately) + // Hide for SuperSync during first-time setup (uses fixed settings; no buttons to host). + // The dialog component drops this hide in edit mode and appends action buttons. { type: 'collapsible', hideExpression: (m, v, field) => field?.parent?.model.syncProvider === SyncProviderId.SuperSync, - props: { label: T.G.ADVANCED_CFG }, + // syncRole is a stable structural marker the dialog routes on, so a + // future global rename of T.G.ADVANCED_CFG cannot silently break it. + props: { label: T.G.ADVANCED_CFG, syncRole: 'advanced' } as SyncCollapsibleProps, fieldGroup: [ + { + key: 'syncInterval', + type: 'duration', + // Hide when manual sync only is enabled (parent.parent reaches the form root) + hideExpression: (m, v, field) => + field?.parent?.parent?.model?.isManualSyncOnly === true, + resetOnHide: true, + templateOptions: { + required: true, + isAllowSeconds: true, + label: T.F.SYNC.FORM.L_SYNC_INTERVAL, + description: T.G.DURATION_DESCRIPTION, + }, + }, + { + key: 'isManualSyncOnly', + type: 'checkbox', + // Only show for file-based providers (Dropbox, WebDAV, LocalFile, Nextcloud) + hideExpression: (m, v, field) => + field?.parent?.parent?.model?.syncProvider === null, + templateOptions: { + label: T.F.SYNC.FORM.L_MANUAL_SYNC_ONLY, + }, + }, { key: 'isCompressionEnabled', type: 'checkbox', @@ -447,6 +443,7 @@ export const SYNC_FORM: ConfigFormSection = { templateOptions: { text: T.F.SYNC.FORM.FILE_BASED.BTN_ENABLE_ENCRYPTION, btnType: 'primary', + btnStyle: 'stroked', onClick: async (field: FormlyFieldConfig) => { const result = await openEnableEncryptionDialogForFileBased(); if (result?.success && field?.model) { @@ -473,16 +470,13 @@ export const SYNC_FORM: ConfigFormSection = { text: T.F.SYNC.FORM.SUPER_SYNC.BTN_GET_TOKEN, tooltip: T.F.SYNC.FORM.SUPER_SYNC.LOGIN_INSTRUCTIONS, btnType: 'primary', + btnStyle: 'stroked', centerBtn: true, onClick: (field: any) => { const baseUrl = field.model.baseUrl || SUPER_SYNC_DEFAULT_BASE_URL; window.open(baseUrl, '_blank'); }, }, - expressionProperties: { - 'templateOptions.btnStyle': (model: any) => - model.accessToken ? 'stroked' : undefined, - }, }, { hideExpression: (m, v, field) => @@ -526,6 +520,7 @@ export const SYNC_FORM: ConfigFormSection = { templateOptions: { text: T.F.SYNC.FORM.SUPER_SYNC.BTN_ENABLE_ENCRYPTION, btnType: 'primary', + btnStyle: 'stroked', onClick: async (field: FormlyFieldConfig) => { const result = await openEnableEncryptionDialog(); if (result?.success && field?.model) { @@ -540,7 +535,10 @@ export const SYNC_FORM: ConfigFormSection = { type: 'collapsible', hideExpression: (m, v, field) => field?.parent?.parent?.model.syncProvider !== SyncProviderId.SuperSync, - props: { label: T.G.ADVANCED_CFG }, + props: { + label: T.G.ADVANCED_CFG, + syncRole: 'advanced', + } as SyncCollapsibleProps, fieldGroup: [ // Server URL { diff --git a/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.html b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.html similarity index 89% rename from src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.html rename to src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.html index b4beff04e0..b3a3c0bd42 100644 --- a/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.html +++ b/src/app/imex/sync/dialog-sync-cfg/dialog-sync-cfg.component.html @@ -1,4 +1,3 @@ -

{{ T.F.SYNC.D_INITIAL_CFG.TITLE | translate }}

@@ -19,7 +18,6 @@
+
+ } @else { +
+ + cloud_sync + {{ status.providerId }} + + @if (status.needsAuth) { + + {{ 'PS.SYNC.NEEDS_AUTH' | translate }} + + } + @if (status.isEncrypted) { + + lock + {{ 'PS.SYNC.ENCRYPTED' | translate }} + + } +
+
+ + +
} diff --git a/src/app/pages/config-page/config-page.component.scss b/src/app/pages/config-page/config-page.component.scss index 4e866e6904..2c4b93a903 100644 --- a/src/app/pages/config-page/config-page.component.scss +++ b/src/app/pages/config-page/config-page.component.scss @@ -106,6 +106,63 @@ } } +.sync-summary { + padding: var(--s2); + + &__intro { + margin: 0 0 var(--s2); + color: var(--text-color-muted); + } + + &__status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s2); + margin-bottom: var(--s2); + } + + &__provider { + display: inline-flex; + align-items: center; + gap: var(--s-half); + font-weight: 500; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } + } + + &__pill { + display: inline-flex; + align-items: center; + gap: var(--s-half); + padding: var(--s-half) var(--s); + border: 1px solid var(--divider-color); + border-radius: 999px; + font-size: 12px; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &--warn { + border-color: var(--color-warning); + color: var(--color-warning); + } + } + + &__actions { + display: flex; + flex-wrap: wrap; + gap: var(--s); + } +} + .version-footer { margin-top: auto; text-align: center; diff --git a/src/app/pages/config-page/config-page.component.spec.ts b/src/app/pages/config-page/config-page.component.spec.ts index 190defe759..bf440dc56e 100644 --- a/src/app/pages/config-page/config-page.component.spec.ts +++ b/src/app/pages/config-page/config-page.component.spec.ts @@ -6,28 +6,39 @@ import { SyncProviderManager } from '../../op-log/sync-providers/provider-manage import { GlobalConfigService } from '../../features/config/global-config.service'; import { ActivatedRoute } from '@angular/router'; import { PluginBridgeService } from '../../plugins/plugin-bridge.service'; -import { WebdavApi } from '../../op-log/sync-providers/file-based/webdav/webdav-api'; import { of } from 'rxjs'; import { signal } from '@angular/core'; import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service'; import { ShareService } from '../../core/share/share.service'; import { UserProfileService } from '../../features/user-profile/user-profile.service'; import { MatDialog } from '@angular/material/dialog'; -import { SyncProviderId } from '../../op-log/sync-providers/provider.const'; import { TranslateService } from '@ngx-translate/core'; describe('ConfigPageComponent', () => { let component: ConfigPageComponent; - let mockSyncConfigService: jasmine.SpyObj; + let mockSyncWrapperService: jasmine.SpyObj; + let mockMatDialog: jasmine.SpyObj; + let mockProviderManager: jasmine.SpyObj; beforeEach(async () => { - mockSyncConfigService = jasmine.createSpyObj( + const mockSyncConfigService = jasmine.createSpyObj( 'SyncConfigService', ['updateSettingsFromForm'], { syncSettingsForm$: of({}) }, ); mockSyncConfigService.updateSettingsFromForm.and.returnValue(Promise.resolve()); + mockSyncWrapperService = jasmine.createSpyObj('SyncWrapperService', ['sync']); + mockMatDialog = jasmine.createSpyObj('MatDialog', ['open']); + mockProviderManager = jasmine.createSpyObj( + 'SyncProviderManager', + ['getProviderById'], + { + currentProviderPrivateCfg$: of(null), + }, + ); + mockProviderManager.getProviderById.and.returnValue(Promise.resolve(undefined)); + await TestBed.configureTestingModule({ providers: [ { provide: SyncConfigService, useValue: mockSyncConfigService }, @@ -35,16 +46,7 @@ describe('ConfigPageComponent', () => { provide: SnackService, useValue: jasmine.createSpyObj('SnackService', ['open']), }, - { - provide: SyncProviderManager, - useValue: (() => { - const spy = jasmine.createSpyObj('SyncProviderManager', ['getProviderById'], { - currentProviderPrivateCfg$: of(null), - }); - spy.getProviderById.and.returnValue(Promise.resolve(undefined)); - return spy; - })(), - }, + { provide: SyncProviderManager, useValue: mockProviderManager }, { provide: GlobalConfigService, useValue: jasmine.createSpyObj('GlobalConfigService', ['updateSection'], { @@ -54,13 +56,10 @@ describe('ConfigPageComponent', () => { }, { provide: ActivatedRoute, useValue: { queryParams: of({}) } }, { provide: PluginBridgeService, useValue: { shortcuts: signal([]) } }, - { provide: SyncWrapperService, useValue: {} }, + { provide: SyncWrapperService, useValue: mockSyncWrapperService }, { provide: ShareService, useValue: {} }, { provide: UserProfileService, useValue: {} }, - { - provide: MatDialog, - useValue: jasmine.createSpyObj('MatDialog', ['open']), - }, + { provide: MatDialog, useValue: mockMatDialog }, { provide: TranslateService, useValue: jasmine.createSpyObj('TranslateService', ['instant']), @@ -75,54 +74,18 @@ describe('ConfigPageComponent', () => { component = TestBed.createComponent(ConfigPageComponent).componentInstance; }); - describe('WebDAV Test Connection button', () => { - it('should save settings after successful connection test', async () => { - // Arrange - spyOn(WebdavApi.prototype, 'testConnection').and.returnValue( - Promise.resolve({ - success: true, - fullUrl: 'https://webdav.example.com/sp-test', - }), - ); + it('should expose an empty syncStatus by default', () => { + expect(component.syncStatus().providerId).toBeNull(); + expect(component.syncStatus().needsAuth).toBe(false); + }); - const webDavCfg = { - baseUrl: 'https://webdav.example.com', - userName: 'testuser', - password: 'testpass', - syncFolderPath: '/sp-test', - }; + it('triggerSync() should call SyncWrapperService.sync()', () => { + component.triggerSync(); + expect(mockSyncWrapperService.sync).toHaveBeenCalled(); + }); - const fullSyncModel = { - isEnabled: true, - syncProvider: SyncProviderId.WebDAV, - syncInterval: 600000, - webDav: webDavCfg, - }; - - const mockField = { - parent: { parent: { model: fullSyncModel } }, - }; - - // Wait for async sync form config to be built - await new Promise((resolve) => setTimeout(resolve, 0)); - - // Find the WebDAV Test Connection button onClick handler - const webDavItem = component - .globalSyncConfigFormCfg()! - .items!.find((item: any) => item.key === 'webDav'); - const testConnectionBtn = webDavItem!.fieldGroup!.find( - (item: any) => item.type === 'btn', - ); - const onClick = testConnectionBtn!.templateOptions!.onClick; - - // Act - await onClick(mockField, {}, webDavCfg); - - // Assert - expect(mockSyncConfigService.updateSettingsFromForm).toHaveBeenCalledWith( - fullSyncModel, - true, - ); - }); + it('openSyncCfgDialog() should open DialogSyncCfgComponent', async () => { + await component.openSyncCfgDialog(); + expect(mockMatDialog.open).toHaveBeenCalled(); }); }); diff --git a/src/app/pages/config-page/config-page.component.ts b/src/app/pages/config-page/config-page.component.ts index 30374439cb..64a4a31c3d 100644 --- a/src/app/pages/config-page/config-page.component.ts +++ b/src/app/pages/config-page/config-page.component.ts @@ -2,11 +2,10 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + DestroyRef, effect, inject, - OnDestroy, OnInit, - signal, } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { GlobalConfigService } from '../../features/config/global-config.service'; @@ -20,13 +19,13 @@ import { } from '../../features/config/global-config-form-config.const'; import { ConfigFormConfig, - ConfigFormSection, GlobalConfigSectionKey, GlobalConfigState, GlobalSectionConfig, - SyncConfig, } from '../../features/config/global-config.model'; -import { combineLatest, firstValueFrom, Observable, Subscription } from 'rxjs'; +import { firstValueFrom, from, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ProjectCfgFormKey } from '../../features/project/project.model'; import { T } from '../../t.const'; import { versions } from '../../../environments/versions'; @@ -36,15 +35,10 @@ import { getAutomaticBackUpFormCfg } from '../../features/config/form-cfgs/autom import { getAppVersionStr } from '../../util/get-app-version-str'; import { ConfigSectionComponent } from '../../features/config/config-section/config-section.component'; import { ConfigSoundFormComponent } from '../../features/config/config-sound-form/config-sound-form.component'; -import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { SYNC_FORM } from '../../features/config/form-cfgs/sync-form.const'; +import { TranslatePipe } from '@ngx-translate/core'; import { EXPERIMENTAL_APP_FEATURE_KEYS } from '../../features/config/form-cfgs/app-features-form.const'; import { SyncProviderManager } from '../../op-log/sync-providers/provider-manager.service'; -import { map } from 'rxjs/operators'; import { SyncConfigService } from '../../imex/sync/sync-config.service'; -import { WebdavApi } from '../../op-log/sync-providers/file-based/webdav/webdav-api'; -import { WebdavPrivateCfg } from '../../op-log/sync-providers/file-based/webdav/webdav.model'; -import { AsyncPipe } from '@angular/common'; import { PluginManagementComponent } from '../../plugins/ui/plugin-management/plugin-management.component'; import { PluginBridgeService } from '../../plugins/plugin-bridge.service'; import { createPluginShortcutFormItems } from '../../features/config/form-cfgs/plugin-keyboard-shortcuts'; @@ -58,12 +52,12 @@ import { SyncWrapperService } from '../../imex/sync/sync-wrapper.service'; import { UserProfileService } from '../../features/user-profile/user-profile.service'; import { MatDialog } from '@angular/material/dialog'; import { DialogDisableProfilesConfirmationComponent } from '../../features/user-profile/dialog-disable-profiles-confirmation/dialog-disable-profiles-confirmation.component'; -import { DialogRestorePointComponent } from '../../imex/sync/dialog-restore-point/dialog-restore-point.component'; import { SyncProviderId } from '../../op-log/sync-providers/provider.const'; import { DialogConfirmComponent } from '../../ui/dialog-confirm/dialog-confirm.component'; import { MatTab, MatTabGroup, MatTabLabel } from '@angular/material/tabs'; import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; +import { MatButton } from '@angular/material/button'; @Component({ selector: 'config-page', @@ -75,16 +69,16 @@ import { MatTooltip } from '@angular/material/tooltip'; ConfigSectionComponent, ConfigSoundFormComponent, TranslatePipe, - AsyncPipe, PluginManagementComponent, MatTabGroup, MatTab, MatTabLabel, MatIcon, MatTooltip, + MatButton, ], }) -export class ConfigPageComponent implements OnInit, OnDestroy { +export class ConfigPageComponent implements OnInit { private readonly _cd = inject(ChangeDetectorRef); private readonly _route = inject(ActivatedRoute); private readonly _providerManager = inject(SyncProviderManager); @@ -94,7 +88,6 @@ export class ConfigPageComponent implements OnInit, OnDestroy { private readonly _shareService = inject(ShareService); private readonly _userProfileService = inject(UserProfileService); private readonly _matDialog = inject(MatDialog); - private readonly _translateService = inject(TranslateService); readonly configService = inject(GlobalConfigService); readonly syncSettingsService = inject(SyncConfigService); @@ -112,31 +105,47 @@ export class ConfigPageComponent implements OnInit, OnDestroy { pluginsShortcutsFormCfg: ConfigFormConfig; globalImexFormCfg: ConfigFormConfig; globalProductivityConfigFormCfg: ConfigFormConfig; - globalSyncConfigFormCfg = signal | undefined>(undefined); - globalCfg?: GlobalConfigState; + // `providerId === null` ⇒ empty state (sync disabled or no provider chosen). + // switchMap drops stale signal writes if a new sync-config emission arrives + // before the previous provider probe resolves — the underlying probe promise + // still runs to completion in the background; only the result is ignored. + // try/catch keeps the stream alive when isReady() rejects (otherwise the + // observable error would kill the subscription and freeze the status). + syncStatus = toSignal( + this.syncSettingsService.syncSettingsForm$.pipe( + switchMap((sync) => { + const providerId = sync.isEnabled + ? (sync.syncProvider as SyncProviderId | null) + : null; + const isEncrypted = !!sync.isEncryptionEnabled; + if (!providerId) { + return of({ providerId: null, needsAuth: false, isEncrypted }); + } + return from( + (async () => { + const provider = await this._providerManager.getProviderById(providerId); + const requiresAuth = !!provider?.getAuthHelper; + try { + const isAuthed = !!(await provider?.isReady()); + return { providerId, needsAuth: requiresAuth && !isAuthed, isEncrypted }; + } catch { + // Don't claim a non-OAuth provider needs auth — only surface + // the auth pill if the provider could plausibly require it. + return { providerId, needsAuth: requiresAuth, isEncrypted }; + } + })(), + ); + }), + ), + { initialValue: { providerId: null, needsAuth: false, isEncrypted: false } }, + ); + appVersion: string = getAppVersionStr(); versions?: typeof versions = versions; - // TODO needs to contain all sync providers.... - // TODO maybe handling this in an effect would be better???? - syncFormCfg$: Observable> = combineLatest([ - this._providerManager.currentProviderPrivateCfg$, - this.configService.sync$, - ]).pipe( - map(([currentProviderCfg, syncCfg]) => { - if (!currentProviderCfg) { - return syncCfg as unknown as Record; - } - return { - ...(syncCfg as unknown as Record), - [currentProviderCfg.providerId]: currentProviderCfg.privateCfg, - }; - }), - ); - - private _subs: Subscription = new Subscription(); + private readonly _destroyRef = inject(DestroyRef); constructor() { // Initialize tab-specific form configurations @@ -147,17 +156,6 @@ export class ConfigPageComponent implements OnInit, OnDestroy { this.globalProductivityConfigFormCfg = GLOBAL_PRODUCTIVITY_FORM_CONFIG.slice(); this.globalTasksFormCfg = GLOBAL_TASKS_FORM_CONFIG.slice(); - // Build sync form config asynchronously (providers are lazy-loaded) - this._buildSyncFormConfig() - .then((cfg) => this.globalSyncConfigFormCfg.set(cfg)) - .catch((err) => { - Log.err('Failed to build sync form config:', err); - this._snackService.open({ - type: 'ERROR', - msg: T.GLOBAL_SNACK.OPEN_SETTINGS_ERROR, - }); - }); - // NOTE: needs special handling cause of the async stuff if (IS_ANDROID_WEB_VIEW) { this.globalImexFormCfg = [...this.globalImexFormCfg, getAutomaticBackUpFormCfg()]; @@ -180,16 +178,16 @@ export class ConfigPageComponent implements OnInit, OnDestroy { } ngOnInit(): void { - this._subs.add( - this.configService.cfg$.subscribe((cfg) => { + this.configService.cfg$ + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe((cfg) => { this.globalCfg = cfg; - // this._cd.detectChanges(); - }), - ); + }); // Check for tab query parameter and set selected tab - this._subs.add( - this._route.queryParams.subscribe((params) => { + this._route.queryParams + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe((params) => { if (params['tab'] !== undefined) { const tabIndex = parseInt(params['tab'], 10); if (!isNaN(tabIndex) && tabIndex >= 0 && tabIndex < 5) { @@ -201,8 +199,7 @@ export class ConfigPageComponent implements OnInit, OnDestroy { this.expandedSection = params['section']; this._cd.detectChanges(); } - }), - ); + }); } private _updateKeyboardFormWithPluginShortcuts(shortcuts: PluginShortcutCfg[]): void { @@ -263,264 +260,14 @@ export class ConfigPageComponent implements OnInit, OnDestroy { this._cd.detectChanges(); } - ngOnDestroy(): void { - this._subs.unsubscribe(); + async openSyncCfgDialog(): Promise { + const { DialogSyncCfgComponent } = + await import('../../imex/sync/dialog-sync-cfg/dialog-sync-cfg.component'); + this._matDialog.open(DialogSyncCfgComponent); } - private async _buildSyncFormConfig(): Promise { - // Pre-load dropbox provider for use in the form config - await this._providerManager.getProviderById(SyncProviderId.Dropbox); - - // Deep clone the SYNC_FORM items to avoid mutating the original - const items = SYNC_FORM.items!.map((item) => { - // Find the WebDAV fieldGroup and add the Test Connection button - if (item.key === 'webDav' && item.fieldGroup) { - return { - ...item, - fieldGroup: [ - ...item.fieldGroup, - { - type: 'btn', - className: 'mt3 block', - templateOptions: { - text: T.F.SYNC.FORM.WEB_DAV.L_TEST_CONNECTION, - required: false, - onClick: async (_field: unknown, _form: unknown, model: unknown) => { - const webDavCfg = model as WebdavPrivateCfg; - if ( - !webDavCfg?.baseUrl || - !webDavCfg?.userName || - !webDavCfg?.password || - !webDavCfg?.syncFolderPath - ) { - this._snackService.open({ - type: 'ERROR', - msg: T.F.SYNC.FORM.WEB_DAV.S_FILL_ALL_FIELDS, - }); - return; - } - - try { - // Create a temporary WebdavApi instance for testing - const api = new WebdavApi(async () => webDavCfg); - const result = await api.testConnection(webDavCfg); - - if (result.success) { - this._snackService.open({ - type: 'SUCCESS', - msg: T.F.SYNC.FORM.WEB_DAV.S_TEST_SUCCESS, - translateParams: { url: result.fullUrl }, - }); - - // Save settings after successful connection test. - // Formly hierarchy: _field (button) -> parent (group wrapper) -> parent (root form) -> model - const fullSyncModel = ( - _field as { parent?: { parent?: { model?: SyncConfig } } } - )?.parent?.parent?.model; - if (fullSyncModel) { - await this.syncSettingsService.updateSettingsFromForm( - fullSyncModel, - true, - ); - } - } else { - this._snackService.open({ - type: 'ERROR', - msg: T.F.SYNC.FORM.WEB_DAV.S_TEST_FAIL, - translateParams: { - error: result.error || 'Unknown error', - url: result.fullUrl, - }, - }); - } - } catch (e) { - this._snackService.open({ - type: 'ERROR', - msg: T.F.SYNC.FORM.WEB_DAV.S_TEST_FAIL, - translateParams: { - error: e instanceof Error ? e.message : 'Unexpected error', - url: (webDavCfg.baseUrl as string) || 'N/A', - }, - }); - } - }, - }, - }, - ], - }; - } - - // Find the Dropbox fieldGroup and add the authentication button - if ((item as any).props?.dropboxAuth && item.fieldGroup) { - // Check if Dropbox is already authenticated - // We need to check auth status asynchronously, so we'll set initial state - // and update it when the form is rendered - let isAuthenticated = false; - this._providerManager - .getProviderById(SyncProviderId.Dropbox) - .then(async (dropboxProvider) => { - const ready = await dropboxProvider?.isReady(); - isAuthenticated = !!ready; - // Update the status field text after checking - const statusField = item.fieldGroup?.find( - (f: any) => f.key === 'authStatus', - ) as any; - if (statusField?.templateOptions) { - statusField.templateOptions.text = isAuthenticated - ? '✓ ' + - this._translateService.instant(T.F.SYNC.FORM.DROPBOX.STATUS_CONFIGURED) - : '⚠ ' + - this._translateService.instant( - T.F.SYNC.FORM.DROPBOX.STATUS_NOT_CONFIGURED, - ); - } - }) - .catch((e) => console.error('Failed to check Dropbox auth status:', e)); - - return { - ...item, - fieldGroup: [ - ...item.fieldGroup, - { - type: 'btn', - className: 'mt3 block e2e-dropbox-auth-btn', - templateOptions: { - // Button text will be determined by checking if already authenticated - text: T.F.SYNC.FORM.DROPBOX.BTN_AUTHENTICATE, - btnType: 'primary', - onClick: async (_field: unknown, _form: unknown, model: unknown) => { - try { - // Check current auth status before opening dialog - const dropboxProvider = await this._providerManager.getProviderById( - SyncProviderId.Dropbox, - ); - const isCurrentlyAuth = await dropboxProvider?.isReady(); - - const result = - await this._syncWrapperService.configuredAuthForSyncProviderIfNecessary( - SyncProviderId.Dropbox, - true, // force re-authentication even if already configured - ); - - if (result.wasConfigured) { - this._snackService.open({ - type: 'SUCCESS', - msg: isCurrentlyAuth - ? T.F.SYNC.FORM.DROPBOX.REAUTH_SUCCESS - : T.F.SYNC.FORM.DROPBOX.AUTH_SUCCESS, - }); - - // Update the status field to show configured - const statusField = item.fieldGroup?.find( - (f: any) => f.key === 'authStatus', - ) as any; - if (statusField?.templateOptions) { - statusField.templateOptions.text = - '✓ ' + - this._translateService.instant( - T.F.SYNC.FORM.DROPBOX.STATUS_CONFIGURED, - ); - } - - // Update button text to "Re-authenticate" - const btnField = _field as any; - if (btnField?.templateOptions) { - btnField.templateOptions.text = - T.F.SYNC.FORM.DROPBOX.BTN_REAUTHENTICATE; - } - - // Save the updated credentials to persist them - const fullSyncModel = ( - _field as { parent?: { parent?: { model?: SyncConfig } } } - )?.parent?.parent?.model; - if (fullSyncModel) { - await this.syncSettingsService.updateSettingsFromForm( - fullSyncModel!, - true, - ); - } - } - } catch (e) { - // Log error for debugging (storage failures, auth errors, etc.) - Log.err('Dropbox authentication failed:', e); - // Show user-friendly error with actual error message for context - this._snackService.open({ - type: 'ERROR', - msg: T.F.SYNC.S.INCOMPLETE_CFG, - translateParams: { - error: e instanceof Error ? e.message : String(e), - }, - }); - } - }, - }, - hooks: { - // Update button text based on auth status when form initializes - onInit: async (field: any) => { - const dropboxProv = await this._providerManager.getProviderById( - SyncProviderId.Dropbox, - ); - const isAuth = await dropboxProv?.isReady(); - if (field?.templateOptions && isAuth) { - field.templateOptions.text = T.F.SYNC.FORM.DROPBOX.BTN_REAUTHENTICATE; - } - }, - }, - }, - ], - }; - } - - return item; - }); - - return { - ...SYNC_FORM, - items: [ - ...items, - { - hideExpression: (m: Record, _v: unknown, field: unknown) => - !m.isEnabled || !(field as { form?: { valid?: boolean } })?.form?.valid, - type: 'btn', - className: 'mt3 block', - templateOptions: { - text: T.F.SYNC.BTN_SYNC_NOW, - required: false, - onClick: () => { - this._syncWrapperService.sync(); - }, - }, - }, - { - hideExpression: (m: Record, _v: unknown, field: unknown) => - !m.isEnabled || !(field as { form?: { valid?: boolean } })?.form?.valid, - type: 'btn', - className: 'mt2 block', - templateOptions: { - text: T.F.SYNC.S.BTN_FORCE_OVERWRITE, - btnType: 'warn', - required: false, - onClick: () => { - this._syncWrapperService.forceUpload(); - }, - }, - }, - { - hideExpression: (m: Record) => - !m.isEnabled || m.syncProvider !== SyncProviderId.SuperSync, - type: 'btn', - className: 'mt2 block', - templateOptions: { - text: T.F.SYNC.BTN_RESTORE_FROM_HISTORY, - btnType: 'stroked', - required: false, - onClick: () => { - this._openRestoreDialog(); - }, - }, - }, - ], - } as typeof SYNC_FORM; + triggerSync(): void { + this._syncWrapperService.sync(); } async saveGlobalCfg($event: { @@ -633,11 +380,4 @@ export class ConfigPageComponent implements OnInit, OnDestroy { }); } } - - private _openRestoreDialog(): void { - this._matDialog.open(DialogRestorePointComponent, { - width: '500px', - maxWidth: '90vw', - }); - } } diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 62bc7319b2..7e5a03cabd 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1281,7 +1281,6 @@ const T = { BTN_CHANGE_PASSWORD: 'F.SYNC.FORM.SUPER_SYNC.BTN_CHANGE_PASSWORD', BTN_DISABLE_ENCRYPTION: 'F.SYNC.FORM.SUPER_SYNC.BTN_DISABLE_ENCRYPTION', BTN_ENABLE_ENCRYPTION: 'F.SYNC.FORM.SUPER_SYNC.BTN_ENABLE_ENCRYPTION', - BTN_FORCE_OVERWRITE: 'F.SYNC.FORM.SUPER_SYNC.BTN_FORCE_OVERWRITE', BTN_GET_TOKEN: 'F.SYNC.FORM.SUPER_SYNC.BTN_GET_TOKEN', CHANGE_PASSWORD_SUCCESS: 'F.SYNC.FORM.SUPER_SYNC.CHANGE_PASSWORD_SUCCESS', CHANGE_PASSWORD_WARNING: 'F.SYNC.FORM.SUPER_SYNC.CHANGE_PASSWORD_WARNING', @@ -2698,6 +2697,13 @@ const T = { PROJECT_SETTINGS: 'PS.PROJECT_SETTINGS', PROVIDE_FEEDBACK: 'PS.PROVIDE_FEEDBACK', RELOAD: 'PS.RELOAD', + SYNC: { + CONFIGURE: 'PS.SYNC.CONFIGURE', + EMPTY_STATE_HINT: 'PS.SYNC.EMPTY_STATE_HINT', + ENCRYPTED: 'PS.SYNC.ENCRYPTED', + NEEDS_AUTH: 'PS.SYNC.NEEDS_AUTH', + SET_UP_SYNC: 'PS.SYNC.SET_UP_SYNC', + }, SYNC_EXPORT: 'PS.SYNC_EXPORT', TABS: { GENERAL: 'PS.TABS.GENERAL', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 71f7374c00..fed1a93bdd 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1177,7 +1177,7 @@ "AUTH_SUCCESS": "Dropbox authentication successful! Credentials saved.", "BTN_AUTHENTICATE": "Authenticate with Dropbox", "BTN_REAUTHENTICATE": "Re-authenticate with Dropbox", - "INFO_TEXT": "Dropbox authentication uses OAuth. Click the button below to authenticate with your Dropbox account.", + "INFO_TEXT": "Dropbox uses OAuth. Saving your settings will open Dropbox in your browser to authorize access.", "L_ACCESS_TOKEN": "Access Token (generated from Auth Code)", "REAUTH_SUCCESS": "Dropbox re-authentication successful! Credentials updated.", "STATUS_CONFIGURED": "Dropbox is configured and ready to sync", @@ -1255,7 +1255,6 @@ "BTN_CHANGE_PASSWORD": "Change Password", "BTN_DISABLE_ENCRYPTION": "Disable Encryption", "BTN_ENABLE_ENCRYPTION": "Enable Encryption", - "BTN_FORCE_OVERWRITE": "Force Overwrite Server", "BTN_GET_TOKEN": "Open Server & Get Token", "CHANGE_PASSWORD_SUCCESS": "Encryption password changed successfully", "CHANGE_PASSWORD_WARNING": "WARNING: This will permanently delete ALL sync history and backups on the server! Previous versions cannot be recovered. Your current data will be re-uploaded with the new password. All other devices will need the new password.", @@ -2645,6 +2644,13 @@ "PROJECT_SETTINGS": "Project Specific Settings", "PROVIDE_FEEDBACK": "Provide Feedback", "RELOAD": "Reload", + "SYNC": { + "CONFIGURE": "Configure", + "EMPTY_STATE_HINT": "Sync your data with Dropbox, WebDAV, Nextcloud, or a local file.", + "ENCRYPTED": "Encrypted", + "NEEDS_AUTH": "Authentication required", + "SET_UP_SYNC": "Set up sync" + }, "SYNC_EXPORT": "Sync & Export", "TABS": { "GENERAL": "General",