diff --git a/apps/client/src/app/components/user-account-access/access-dialog-host/access-dialog-host.component.ts b/apps/client/src/app/components/user-account-access/access-dialog-host/access-dialog-host.component.ts new file mode 100644 index 000000000..d2bb4611c --- /dev/null +++ b/apps/client/src/app/components/user-account-access/access-dialog-host/access-dialog-host.component.ts @@ -0,0 +1,179 @@ +import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { Access } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; +import { DataService } from '@ghostfolio/ui/services'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + OnDestroy, + OnInit, + inject +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { ActivatedRoute, Router } from '@angular/router'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { Observable, Subject, of } from 'rxjs'; +import { + distinctUntilChanged, + map, + switchMap, + takeUntil, + tap +} from 'rxjs/operators'; + +import { GfCreateOrUpdateAccessDialogComponent } from '../create-or-update-access-dialog/create-or-update-access-dialog.component'; +import { CreateOrUpdateAccessDialogParams } from '../create-or-update-access-dialog/interfaces/interfaces'; +import { AccessDialogMode } from './types/access-dialog-mode.type'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-access-dialog-host', + template: '' +}) +export class GfAccessDialogHostComponent implements OnDestroy, OnInit { + private dialogRef: MatDialogRef; + + private readonly deviceType = computed(() => { + return this.deviceDetectorService.deviceInfo().deviceType; + }); + + private readonly dialogClosed = new Subject(); + + private readonly dataService = inject(DataService); + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly dialog = inject(MatDialog); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly userService = inject(UserService); + + public ngOnInit() { + const mode = this.route.snapshot.data.mode as AccessDialogMode; + + // The router reuses this component when only the access id changes, so + // the parameters are observed instead of read from the snapshot once + this.route.paramMap + .pipe( + map((paramMap) => { + return paramMap.get('accessId'); + }), + distinctUntilChanged(), + tap(() => { + this.closeDialog(); + }), + switchMap((accessId) => { + const access$: Observable = + mode === 'update' && accessId + ? this.fetchAccess(accessId) + : of(undefined); + + return this.userService.get().pipe( + switchMap((user) => { + return access$.pipe( + map((access) => { + return { access, user }; + }) + ); + }) + ); + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + error: () => { + this.navigateBack(); + }, + next: ({ access, user }) => { + if (mode === 'update') { + if ( + !access || + !hasPermission(user?.permissions, permissions.updateAccess) + ) { + this.navigateBack(); + + return; + } + + this.openCreateOrUpdateAccessDialog({ access }); + + return; + } + + if (!hasPermission(user?.permissions, permissions.createAccess)) { + this.navigateBack(); + + return; + } + + this.openCreateOrUpdateAccessDialog({}); + } + }); + } + + public ngOnDestroy() { + // The dialog lives in an overlay outside of this component, so it needs to + // be closed explicitly when leaving the route (for example via the browser + // navigation) + this.dialogRef?.close(); + + this.dialogClosed.complete(); + } + + private closeDialog() { + // Tear down the subscription of the dialog which is about to be replaced, + // so that its result is not mistaken for the user closing it + this.dialogClosed.next(); + + this.dialogRef?.close(); + } + + private fetchAccess(aAccessId: string) { + return this.dataService.fetchAccesses().pipe( + map((accesses) => { + return accesses.find(({ id }) => { + return id === aAccessId; + }); + }) + ); + } + + private navigateBack() { + void this.router.navigate( + internalRoutes.account.subRoutes.access.routerLink + ); + } + + private openCreateOrUpdateAccessDialog( + data: CreateOrUpdateAccessDialogParams + ) { + const dialogRef = this.dialog.open< + GfCreateOrUpdateAccessDialogComponent, + CreateOrUpdateAccessDialogParams + >(GfCreateOrUpdateAccessDialogComponent, { + data, + height: this.deviceType() === 'mobile' ? '98vh' : undefined, + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' + }); + + this.dialogRef = dialogRef; + + dialogRef + .afterClosed() + .pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef)) + .subscribe((result) => { + if (result) { + // Deliberately not bound to the destroy reference: navigating back + // destroys this component and the refreshed user is what makes the + // access page reload its data + this.userService.get(true).subscribe(); + } + + this.navigateBack(); + }); + } +} diff --git a/apps/client/src/app/components/user-account-access/access-dialog-host/types/access-dialog-mode.type.ts b/apps/client/src/app/components/user-account-access/access-dialog-host/types/access-dialog-mode.type.ts new file mode 100644 index 000000000..163b53d9e --- /dev/null +++ b/apps/client/src/app/components/user-account-access/access-dialog-host/types/access-dialog-mode.type.ts @@ -0,0 +1 @@ +export type AccessDialogMode = 'create' | 'update'; diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index a21ddc343..48fe66d2f 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -1,10 +1,10 @@ import { GfAccessTableComponent } from '@ghostfolio/client/components/access-table/access-table.component'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { CreateAccessDto } from '@ghostfolio/common/dtos'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { Access, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { GfFabComponent } from '@ghostfolio/ui/fab'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; @@ -14,11 +14,9 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - computed, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - inject, - OnInit + inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { @@ -28,20 +26,15 @@ import { Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; -import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; import { addOutline, eyeOffOutline, eyeOutline } from 'ionicons/icons'; -import { DeviceDetectorService } from 'ngx-device-detector'; import { EMPTY } from 'rxjs'; import { catchError, switchMap } from 'rxjs/operators'; -import { GfCreateOrUpdateAccessDialogComponent } from './create-or-update-access-dialog/create-or-update-access-dialog.component'; -import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dialog/interfaces/interfaces'; - @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ @@ -50,7 +43,6 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial GfPremiumIndicatorComponent, IonIcon, MatButtonModule, - MatDialogModule, MatFormFieldModule, MatInputModule, ReactiveFormsModule, @@ -61,7 +53,7 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial styleUrls: ['./user-account-access.scss'], templateUrl: './user-account-access.html' }) -export class GfUserAccountAccessComponent implements OnInit { +export class GfUserAccountAccessComponent { protected accessesGet: Access[]; protected accessesGive: Access[]; protected hasImpersonationId: boolean; @@ -69,6 +61,7 @@ export class GfUserAccountAccessComponent implements OnInit { protected hasPermissionToDeleteAccess: boolean; protected hasPermissionToUpdateOwnAccessToken: boolean; protected isAccessTokenHidden = true; + protected readonly internalRoutes = internalRoutes; protected readonly updateOwnAccessTokenForm = new FormGroup({ accessToken: new FormControl('', { nonNullable: true, @@ -77,20 +70,13 @@ export class GfUserAccountAccessComponent implements OnInit { }); protected user: User; - private readonly deviceType = computed( - () => this.deviceDetectorService.deviceInfo().deviceType - ); - private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); - private readonly deviceDetectorService = inject(DeviceDetectorService); - private readonly dialog = inject(MatDialog); private readonly impersonationStorageService = inject( ImpersonationStorageService ); private readonly notificationService = inject(NotificationService); - private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly userService = inject(UserService); @@ -132,27 +118,15 @@ export class GfUserAccountAccessComponent implements OnInit { permissions.updateOwnAccessToken ); - this.changeDetectorRef.markForCheck(); - } - }); + this.update(); - this.route.queryParams - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((params) => { - if (params['createDialog']) { - this.openCreateAccessDialog(); - } else if (params['editDialog'] && params['accessId']) { - this.openUpdateAccessDialog(params['accessId']); + this.changeDetectorRef.markForCheck(); } }); addIcons({ addOutline, eyeOffOutline, eyeOutline }); } - public ngOnInit() { - this.update(); - } - protected onDeleteAccess(aId: string) { this.dataService .deleteAccess(aId) @@ -228,64 +202,9 @@ export class GfUserAccountAccessComponent implements OnInit { } protected onUpdateAccess(aId: string) { - this.router.navigate([], { - queryParams: { accessId: aId, editDialog: true } - }); - } - - private openCreateAccessDialog() { - const dialogRef = this.dialog.open< - GfCreateOrUpdateAccessDialogComponent, - CreateOrUpdateAccessDialogParams - >(GfCreateOrUpdateAccessDialogComponent, { - data: {} satisfies CreateOrUpdateAccessDialogParams, - height: this.deviceType() === 'mobile' ? '98vh' : undefined, - width: this.deviceType() === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef.afterClosed().subscribe((access: CreateAccessDto | null) => { - if (access) { - this.update(); - } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); - } - - private openUpdateAccessDialog(accessId: string) { - const access = this.accessesGive?.find(({ id }) => { - return id === accessId; - }); - - if (!access) { - return; - } - - const dialogRef = this.dialog.open< - GfCreateOrUpdateAccessDialogComponent, - CreateOrUpdateAccessDialogParams - >(GfCreateOrUpdateAccessDialogComponent, { - data: { - access: { - alias: access.alias, - grantee: access.grantee, - id: access.id, - scopes: access.scopes, - settings: access.settings, - type: access.type - } - } satisfies CreateOrUpdateAccessDialogParams, - height: this.deviceType() === 'mobile' ? '98vh' : undefined, - width: this.deviceType() === 'mobile' ? '100vw' : '50rem' - }); - - dialogRef.afterClosed().subscribe((result) => { - if (result) { - this.update(); - } - - this.router.navigate(['.'], { relativeTo: this.route }); - }); + void this.router.navigate( + internalRoutes.account.subRoutes.access.subRoutes.update.routerLink(aId) + ); } private update() { diff --git a/apps/client/src/app/components/user-account-access/user-account-access.html b/apps/client/src/app/components/user-account-access/user-account-access.html index a2f755029..cacf682ae 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.html +++ b/apps/client/src/app/components/user-account-access/user-account-access.html @@ -76,6 +76,12 @@ (accessToUpdate)="onUpdateAccess($event)" /> @if (hasPermissionToCreateAccess) { - + } + + diff --git a/apps/client/src/app/pages/user-account/user-account-page.routes.ts b/apps/client/src/app/pages/user-account/user-account-page.routes.ts index 5d0f5b202..250bb5088 100644 --- a/apps/client/src/app/pages/user-account/user-account-page.routes.ts +++ b/apps/client/src/app/pages/user-account/user-account-page.routes.ts @@ -1,3 +1,4 @@ +import { GfAccessDialogHostComponent } from '@ghostfolio/client/components/user-account-access/access-dialog-host/access-dialog-host.component'; import { GfUserAccountAccessComponent } from '@ghostfolio/client/components/user-account-access/user-account-access.component'; import { GfUserAccountMembershipComponent } from '@ghostfolio/client/components/user-account-membership/user-account-membership.component'; import { GfUserAccountSettingsComponent } from '@ghostfolio/client/components/user-account-settings/user-account-settings.component'; @@ -8,24 +9,45 @@ import { Routes } from '@angular/router'; import { GfUserAccountPageComponent } from './user-account-page.component'; +const { access, membership } = internalRoutes.account.subRoutes; + export const routes: Routes = [ { canActivate: [AuthGuard], children: [ { - path: '', component: GfUserAccountSettingsComponent, + path: '', title: internalRoutes.account.title }, { - path: internalRoutes.account.subRoutes.membership.path, component: GfUserAccountMembershipComponent, - title: internalRoutes.account.subRoutes.membership.title + path: membership.path, + title: membership.title }, { - path: internalRoutes.account.subRoutes.access.path, + children: [ + { + component: GfAccessDialogHostComponent, + data: { mode: 'create' }, + path: access.subRoutes.create.path, + title: access.subRoutes.create.title + }, + { + children: [ + { + component: GfAccessDialogHostComponent, + data: { mode: 'update' }, + path: access.subRoutes.update.path, + title: access.subRoutes.update.title + } + ], + path: ':accessId' + } + ], component: GfUserAccountAccessComponent, - title: internalRoutes.account.subRoutes.access.title + path: access.path, + title: access.title } ], component: GfUserAccountPageComponent, diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts index d1cc1d430..6985ba04f 100644 --- a/libs/common/src/lib/routes/routes.ts +++ b/libs/common/src/lib/routes/routes.ts @@ -23,6 +23,20 @@ export const internalRoutes = { access: { path: 'access', routerLink: ['/account', 'access'], + subRoutes: { + create: { + path: 'create', + routerLink: ['/account', 'access', 'create'], + title: $localize`Grant access` + }, + update: { + path: 'update', + routerLink: (aAccessId: string) => { + return ['/account', 'access', aAccessId, 'update']; + }, + title: $localize`Edit access` + } + }, title: $localize`Access` }, membership: {