diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7c08d17a..5e1a45cd7 100644
--- a/CHANGELOG.md
+++ b/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/),
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
### Added
diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html
index 04c8249b2..c6e504d6c 100644
--- a/apps/client/src/app/components/access-table/access-table.component.html
+++ b/apps/client/src/app/components/access-table/access-table.component.html
@@ -77,12 +77,15 @@
@if (
!isReceivedAccess() && user()?.settings?.isExperimentalFeatures
) {
-
+
Edit ...
-
+
}
@if (element.type === 'PUBLIC') {
diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts
index 0d1e6193a..6b839e56e 100644
--- a/apps/client/src/app/components/access-table/access-table.component.ts
+++ b/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 { Access, User } from '@ghostfolio/common/interfaces';
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 { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon';
import { NotificationService } from '@ghostfolio/ui/notifications';
@@ -60,7 +60,18 @@ export class GfAccessTableComponent {
public readonly user = input.required();
public readonly accessDeleted = output();
- public readonly accessToUpdate = output();
+
+ protected readonly accessDialogRouterLinks = computed(() => {
+ const { update } = internalRoutes.account.subRoutes.access.subRoutes;
+
+ const routerLinks = new Map();
+
+ for (const { id } of this.accesses() ?? []) {
+ routerLinks.set(id, update.routerLink(id));
+ }
+
+ return routerLinks;
+ });
protected readonly baseUrl = window.location.origin;
protected readonly dataSource = new MatTableDataSource();
@@ -150,8 +161,4 @@ export class GfAccessTableComponent {
: $localize`Do you really want to revoke this granted access?`
});
}
-
- protected onUpdateAccess(aId: string) {
- this.accessToUpdate.emit(aId);
- }
}
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..72e92700d
--- /dev/null
+++ b/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;
+
+ 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 };
+ })
+ );
+ }),
+ 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();
+ });
+ }
+}
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..4336cf752 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 { 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,13 +53,14 @@ 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;
protected hasPermissionToCreateAccess: boolean;
protected hasPermissionToDeleteAccess: boolean;
protected hasPermissionToUpdateOwnAccessToken: boolean;
+ protected readonly internalRoutes = internalRoutes;
protected isAccessTokenHidden = true;
protected readonly updateOwnAccessTokenForm = new FormGroup({
accessToken: new FormControl('', {
@@ -77,21 +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);
public constructor() {
@@ -132,27 +117,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)
@@ -187,9 +160,7 @@ export class GfUserAccountAccessComponent implements OnInit {
}),
takeUntilDestroyed(this.destroyRef)
)
- .subscribe(() => {
- this.update();
- });
+ .subscribe();
}
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() {
this.accessesGet = this.user.access.map(({ alias, id, scopes }) => {
return {
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..9694c9456 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
@@ -73,9 +73,14 @@
[showActions]="hasPermissionToDeleteAccess"
[user]="user"
(accessDeleted)="onDeleteAccess($event)"
- (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: {