Browse Source

Task/migrate create and edit access dialogs to dedicated routes (#7711)

* Migrate create and edit access dialogs to dedicated routes

* Update changelog
pull/7718/head
Thomas Kaul 4 days ago
committed by GitHub
parent
commit
9be7317006
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 7
      apps/client/src/app/components/access-table/access-table.component.html
  3. 19
      apps/client/src/app/components/access-table/access-table.component.ts
  4. 180
      apps/client/src/app/components/user-account-access/access-dialog-host/access-dialog-host.component.ts
  5. 1
      apps/client/src/app/components/user-account-access/access-dialog-host/types/access-dialog-mode.type.ts
  6. 106
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  7. 9
      apps/client/src/app/components/user-account-access/user-account-access.html
  8. 32
      apps/client/src/app/pages/user-account/user-account-page.routes.ts
  9. 14
      libs/common/src/lib/routes/routes.ts

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Changed
- Migrated the create and edit access dialogs to dedicated routes
## 3.60.0 - 2026-08-24 ## 3.60.0 - 2026-08-24
### Added ### Added

7
apps/client/src/app/components/access-table/access-table.component.html

@ -77,12 +77,15 @@
@if ( @if (
!isReceivedAccess() && user()?.settings?.isExperimentalFeatures !isReceivedAccess() && user()?.settings?.isExperimentalFeatures
) { ) {
<button mat-menu-item (click)="onUpdateAccess(element.id)"> <a
mat-menu-item
[routerLink]="accessDialogRouterLinks().get(element.id)"
>
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />
<span><ng-container i18n>Edit</ng-container>...</span> <span><ng-container i18n>Edit</ng-container>...</span>
</span> </span>
</button> </a>
} }
@if (element.type === 'PUBLIC') { @if (element.type === 'PUBLIC') {
<button mat-menu-item (click)="onCopyUrlToClipboard(element.id)"> <button mat-menu-item (click)="onCopyUrlToClipboard(element.id)">

19
apps/client/src/app/components/access-table/access-table.component.ts

@ -2,7 +2,7 @@ import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { getAccessLevel } from '@ghostfolio/common/scopes'; import { getAccessLevel } from '@ghostfolio/common/scopes';
import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon'; import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
@ -60,7 +60,18 @@ export class GfAccessTableComponent {
public readonly user = input.required<User>(); public readonly user = input.required<User>();
public readonly accessDeleted = output<string>(); public readonly accessDeleted = output<string>();
public readonly accessToUpdate = output<string>();
protected readonly accessDialogRouterLinks = computed(() => {
const { update } = internalRoutes.account.subRoutes.access.subRoutes;
const routerLinks = new Map<string, string[]>();
for (const { id } of this.accesses() ?? []) {
routerLinks.set(id, update.routerLink(id));
}
return routerLinks;
});
protected readonly baseUrl = window.location.origin; protected readonly baseUrl = window.location.origin;
protected readonly dataSource = new MatTableDataSource<Access>(); protected readonly dataSource = new MatTableDataSource<Access>();
@ -150,8 +161,4 @@ export class GfAccessTableComponent {
: $localize`Do you really want to revoke this granted access?` : $localize`Do you really want to revoke this granted access?`
}); });
} }
protected onUpdateAccess(aId: string) {
this.accessToUpdate.emit(aId);
}
} }

180
apps/client/src/app/components/user-account-access/access-dialog-host/access-dialog-host.component.ts

@ -0,0 +1,180 @@
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 { EMPTY, Observable, Subject, of } from 'rxjs';
import {
catchError,
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<GfCreateOrUpdateAccessDialogComponent>;
private readonly deviceType = computed(() => {
return this.deviceDetectorService.deviceInfo().deviceType;
});
private readonly dialogClosed = new Subject<void>();
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<Access | undefined> =
mode === 'update' && accessId
? this.fetchAccess(accessId)
: of(undefined);
return this.userService.get().pipe(
switchMap((user) => {
return access$.pipe(
map((access) => {
return { access, user };
})
);
}),
catchError(() => {
this.navigateBack();
return EMPTY;
})
);
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ 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();
});
}
}

1
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';

106
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 { GfAccessTableComponent } from '@ghostfolio/client/components/access-table/access-table.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccessDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -14,11 +14,9 @@ import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
computed,
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
DestroyRef, DestroyRef,
inject, inject
OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
@ -28,20 +26,15 @@ import {
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { addOutline, eyeOffOutline, eyeOutline } from 'ionicons/icons'; import { addOutline, eyeOffOutline, eyeOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators'; 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({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
@ -50,7 +43,6 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
ReactiveFormsModule, ReactiveFormsModule,
@ -61,13 +53,14 @@ import { CreateOrUpdateAccessDialogParams } from './create-or-update-access-dial
styleUrls: ['./user-account-access.scss'], styleUrls: ['./user-account-access.scss'],
templateUrl: './user-account-access.html' templateUrl: './user-account-access.html'
}) })
export class GfUserAccountAccessComponent implements OnInit { export class GfUserAccountAccessComponent {
protected accessesGet: Access[]; protected accessesGet: Access[];
protected accessesGive: Access[]; protected accessesGive: Access[];
protected hasImpersonationId: boolean; protected hasImpersonationId: boolean;
protected hasPermissionToCreateAccess: boolean; protected hasPermissionToCreateAccess: boolean;
protected hasPermissionToDeleteAccess: boolean; protected hasPermissionToDeleteAccess: boolean;
protected hasPermissionToUpdateOwnAccessToken: boolean; protected hasPermissionToUpdateOwnAccessToken: boolean;
protected readonly internalRoutes = internalRoutes;
protected isAccessTokenHidden = true; protected isAccessTokenHidden = true;
protected readonly updateOwnAccessTokenForm = new FormGroup({ protected readonly updateOwnAccessTokenForm = new FormGroup({
accessToken: new FormControl<string>('', { accessToken: new FormControl<string>('', {
@ -77,21 +70,13 @@ export class GfUserAccountAccessComponent implements OnInit {
}); });
protected user: User; protected user: User;
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject( private readonly impersonationStorageService = inject(
ImpersonationStorageService ImpersonationStorageService
); );
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
@ -132,27 +117,15 @@ export class GfUserAccountAccessComponent implements OnInit {
permissions.updateOwnAccessToken permissions.updateOwnAccessToken
); );
this.changeDetectorRef.markForCheck(); this.update();
}
});
this.route.queryParams this.changeDetectorRef.markForCheck();
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateAccessDialog();
} else if (params['editDialog'] && params['accessId']) {
this.openUpdateAccessDialog(params['accessId']);
} }
}); });
addIcons({ addOutline, eyeOffOutline, eyeOutline }); addIcons({ addOutline, eyeOffOutline, eyeOutline });
} }
public ngOnInit() {
this.update();
}
protected onDeleteAccess(aId: string) { protected onDeleteAccess(aId: string) {
this.dataService this.dataService
.deleteAccess(aId) .deleteAccess(aId)
@ -187,9 +160,7 @@ export class GfUserAccountAccessComponent implements OnInit {
}), }),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe();
this.update();
});
} }
protected onGenerateAccessToken() { protected onGenerateAccessToken() {
@ -227,67 +198,6 @@ 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 });
});
}
private update() { private update() {
this.accessesGet = this.user.access.map(({ alias, id, scopes }) => { this.accessesGet = this.user.access.map(({ alias, id, scopes }) => {
return { return {

9
apps/client/src/app/components/user-account-access/user-account-access.html

@ -73,9 +73,14 @@
[showActions]="hasPermissionToDeleteAccess" [showActions]="hasPermissionToDeleteAccess"
[user]="user" [user]="user"
(accessDeleted)="onDeleteAccess($event)" (accessDeleted)="onDeleteAccess($event)"
(accessToUpdate)="onUpdateAccess($event)"
/> />
@if (hasPermissionToCreateAccess) { @if (hasPermissionToCreateAccess) {
<gf-fab [queryParams]="{ createDialog: true }" /> <gf-fab
[routerLink]="
internalRoutes.account.subRoutes.access.subRoutes.create.routerLink
"
/>
} }
</div> </div>
<router-outlet />

32
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 { 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 { 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'; 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'; import { GfUserAccountPageComponent } from './user-account-page.component';
const { access, membership } = internalRoutes.account.subRoutes;
export const routes: Routes = [ export const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
children: [ children: [
{ {
path: '',
component: GfUserAccountSettingsComponent, component: GfUserAccountSettingsComponent,
path: '',
title: internalRoutes.account.title title: internalRoutes.account.title
}, },
{ {
path: internalRoutes.account.subRoutes.membership.path,
component: GfUserAccountMembershipComponent, component: GfUserAccountMembershipComponent,
title: internalRoutes.account.subRoutes.membership.title path: membership.path,
title: membership.title
},
{
children: [
{
component: GfAccessDialogHostComponent,
data: { mode: 'create' },
path: access.subRoutes.create.path,
title: access.subRoutes.create.title
}, },
{ {
path: internalRoutes.account.subRoutes.access.path, children: [
{
component: GfAccessDialogHostComponent,
data: { mode: 'update' },
path: access.subRoutes.update.path,
title: access.subRoutes.update.title
}
],
path: ':accessId'
}
],
component: GfUserAccountAccessComponent, component: GfUserAccountAccessComponent,
title: internalRoutes.account.subRoutes.access.title path: access.path,
title: access.title
} }
], ],
component: GfUserAccountPageComponent, component: GfUserAccountPageComponent,

14
libs/common/src/lib/routes/routes.ts

@ -23,6 +23,20 @@ export const internalRoutes = {
access: { access: {
path: 'access', path: 'access',
routerLink: ['/account', '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` title: $localize`Access`
}, },
membership: { membership: {

Loading…
Cancel
Save