From 853914ca140f61ff0c19f64db50d14cbb2ab001b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:40:18 +0200 Subject: [PATCH 01/11] Bugfix/localization of date pickers (#8232) * Fix localization of date pickers * Update changelog --- CHANGELOG.md | 5 +++++ apps/client/src/app/app.component.ts | 18 +++++++++++++++++- ...create-or-update-access-dialog.component.ts | 6 +----- ...eate-or-update-activity-dialog.component.ts | 13 +------------ .../account-balances.component.ts | 9 +-------- ...ical-market-data-editor-dialog.component.ts | 11 +---------- 6 files changed, 26 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e34aa4caa..c31fecf7b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the performance of the historical market data table of the admin control panel by loading only the latest market price per asset profile - Introduced a cooldown of 12 hours for the asset profile and exchange rate gathering on activity creation by retaining the completed jobs +### Fixed + +- Fixed the missing localization of the date picker in the _FIRE_ calculator +- Fixed the missing localization of the date picker in the asset profile details dialog of the admin control panel + ## 3.88.0 - 2026-10-17 ### Added diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index c9e124723d..53d3505a1c 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -25,6 +25,7 @@ import { OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DateAdapter } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; import { Title } from '@angular/platform-browser'; import { @@ -39,7 +40,7 @@ import { Chart } from 'chart.js'; import { addIcons } from 'ionicons'; import { openOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { filter } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { GfFooterComponent } from './components/footer/footer.component'; import { GfHeaderComponent } from './components/header/header.component'; @@ -75,6 +76,7 @@ export class GfAppComponent implements OnInit { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); + private readonly dateAdapter = inject>(DateAdapter); private readonly destroyRef = inject(DestroyRef); private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly dialog = inject(MatDialog); @@ -212,6 +214,20 @@ export class GfAppComponent implements OnInit { this.changeDetectorRef.markForCheck(); }); + this.userService.stateChanged + .pipe( + map(({ user }) => { + return user?.settings.locale; + }), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((locale) => { + if (locale) { + this.dateAdapter.setLocale(locale); + } + }); + this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index afb0e2cea3..17d671c9b2 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -1,5 +1,5 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { DEFAULT_LOCALE, MCP_ENDPOINT } from '@ghostfolio/common/config'; +import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { canApplyFiltersToAccess } from '@ghostfolio/common/helper'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; @@ -48,7 +48,6 @@ import { Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { DateAdapter } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, @@ -108,7 +107,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { private readonly data = inject(MAT_DIALOG_DATA); - private readonly dateAdapter = inject>(DateAdapter); private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); @@ -189,8 +187,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false; this.tags = getTagFilters(tags); - this.dateAdapter.setLocale(settings.locale ?? DEFAULT_LOCALE); - this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index c041b565aa..d3bc34acee 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -6,10 +6,7 @@ import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; -import { - getDateFormatString, - getStringOrNull -} from '@ghostfolio/common/helper'; +import { getStringOrNull } from '@ghostfolio/common/helper'; import { AssetClassSelectorOption, LookupItem @@ -41,7 +38,6 @@ import { } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, @@ -102,7 +98,6 @@ export class GfCreateOrUpdateActivityDialogComponent { protected currencies: string[] = []; protected currencyOfAssetProfile: string | undefined; protected currentMarketPrice: number | null = null; - protected defaultDateFormat: string; protected defaultLookupItems: LookupItem[] = []; protected hasPermissionToCreateOwnTag: boolean; protected isLoading = false; @@ -119,7 +114,6 @@ export class GfCreateOrUpdateActivityDialogComponent { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); - private readonly dateAdapter = inject>(DateAdapter); private readonly destroyRef = inject(DestroyRef); private readonly dialogRef = inject>(MatDialogRef); @@ -127,7 +121,6 @@ export class GfCreateOrUpdateActivityDialogComponent { private readonly impersonationStorageService = inject( ImpersonationStorageService ); - private locale = inject(MAT_DATE_LOCALE); private readonly userService = inject(UserService); public constructor() { @@ -143,15 +136,11 @@ export class GfCreateOrUpdateActivityDialogComponent { !this.impersonationStorageService.getId() && hasPermission(this.data.user?.permissions, permissions.createOwnTag); - this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE; this.mode = this.data.activity?.id ? 'update' : 'create'; - this.dateAdapter.setLocale(this.locale); - const { currencies } = this.dataService.fetchInfo(); this.currencies = currencies; - this.defaultDateFormat = getDateFormatString(this.locale); this.dataService .fetchPortfolioHoldings({ diff --git a/libs/ui/src/lib/account-balances/account-balances.component.ts b/libs/ui/src/lib/account-balances/account-balances.component.ts index 678650b2cb..715b4af0ec 100644 --- a/libs/ui/src/lib/account-balances/account-balances.component.ts +++ b/libs/ui/src/lib/account-balances/account-balances.component.ts @@ -11,7 +11,6 @@ import { Component, EventEmitter, OnChanges, - OnInit, Output, effect, inject, @@ -25,7 +24,6 @@ import { ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { DateAdapter } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -63,7 +61,7 @@ import { GfValueComponent } from '../value'; styleUrls: ['./account-balances.component.scss'], templateUrl: './account-balances.component.html' }) -export class GfAccountBalancesComponent implements OnChanges, OnInit { +export class GfAccountBalancesComponent implements OnChanges { @Output() accountBalanceCreated = new EventEmitter(); @Output() accountBalanceDeleted = new EventEmitter(); @@ -94,7 +92,6 @@ export class GfAccountBalancesComponent implements OnChanges, OnInit { >(); public maxDate = new Date(); - private dateAdapter = inject>(DateAdapter); private notificationService = inject(NotificationService); public constructor() { @@ -112,10 +109,6 @@ export class GfAccountBalancesComponent implements OnChanges, OnInit { }); } - public ngOnInit() { - this.dateAdapter.setLocale(this.locale()); - } - public ngOnChanges() { if (this.accountBalances()) { this.dataSource = new MatTableDataSource(this.accountBalances()); diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts index 4f8540974f..358dbbbcd4 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts @@ -6,7 +6,6 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - OnInit, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -17,7 +16,6 @@ import { Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, @@ -50,7 +48,7 @@ import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces' styleUrls: ['./historical-market-data-editor-dialog.scss'], templateUrl: 'historical-market-data-editor-dialog.html' }) -export class GfHistoricalMarketDataEditorDialogComponent implements OnInit { +export class GfHistoricalMarketDataEditorDialogComponent { public readonly data = inject(MAT_DIALOG_DATA); @@ -60,23 +58,16 @@ export class GfHistoricalMarketDataEditorDialogComponent implements OnInit { }); private readonly destroyRef = inject(DestroyRef); - private readonly locale = - this.data.user.settings.locale ?? inject(MAT_DATE_LOCALE); public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, - private dateAdapter: DateAdapter, public dialogRef: MatDialogRef ) { addIcons({ calendarClearOutline, refreshOutline }); } - public ngOnInit() { - this.dateAdapter.setLocale(this.locale); - } - public onCancel() { this.dialogRef.close({ withRefresh: false }); } From 892c0e0aab65182de184419b74c36c0186049074 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:47:31 +0200 Subject: [PATCH 02/11] Bugfix/missing tooltip of client of last request in users table (#8230) * Fix missing tooltip * Update changelog --- CHANGELOG.md | 1 + apps/client/src/app/components/admin-users/admin-users.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c31fecf7b9..73207ee4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the missing localization of the date picker in the _FIRE_ calculator - Fixed the missing localization of the date picker in the asset profile details dialog of the admin control panel +- Fixed the missing tooltip of the client of the last request in the users table of the admin control panel ## 3.88.0 - 2026-10-17 diff --git a/apps/client/src/app/components/admin-users/admin-users.html b/apps/client/src/app/components/admin-users/admin-users.html index 7b49dc5f60..98056f00f7 100644 --- a/apps/client/src/app/components/admin-users/admin-users.html +++ b/apps/client/src/app/components/admin-users/admin-users.html @@ -199,7 +199,7 @@
@if (element.lastRequestClient) { From 7e54ba65929de0ef639830e2139366f96b600602 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:46:13 +0200 Subject: [PATCH 03/11] Task/extend app module spec by route snapshot (#8227) Extend app module spec by route snapshot --- .../app/__snapshots__/app.module.spec.ts.snap | 1183 +++++++++++++++++ apps/api/src/app/app.module.spec.ts | 212 ++- 2 files changed, 1372 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/app/__snapshots__/app.module.spec.ts.snap diff --git a/apps/api/src/app/__snapshots__/app.module.spec.ts.snap b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap new file mode 100644 index 0000000000..34cdbb907a --- /dev/null +++ b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap @@ -0,0 +1,1183 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AppModule should keep the security of each route 1`] = ` +{ + "DELETE /access/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteAccess", + }, + "DELETE /account-balance/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "deleteAccountBalance", + "scopes": [ + "account:update", + ], + }, + "DELETE /account/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "deleteAccount", + "scopes": [ + "account:delete", + ], + }, + "DELETE /activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "deleteActivity", + "scopes": [ + "activity:delete", + ], + }, + "DELETE /activities/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "deleteActivity", + "scopes": [ + "activity:delete", + ], + }, + "DELETE /admin/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /admin/queue/job": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /admin/queue/job/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /asset-profiles/:dataSource/:symbol/splits/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "DELETE /auth-device/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteAuthDevice", + }, + "DELETE /benchmarks/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /platform/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deletePlatform", + }, + "DELETE /tags/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteTag", + }, + "DELETE /user": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + ], + "interceptors": [], + "permission": "deleteOwnUser", + }, + "DELETE /user/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteUser", + }, + "DELETE /watchlist/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "deleteWatchlistItem", + "scopes": [ + "watchlist:delete", + ], + }, + "GET /access": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /account": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "account:read", + ], + }, + "GET /account/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "account:read", + ], + }, + "GET /account/:id/balances": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "account:read", + ], + }, + "GET /activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "activity:read", + ], + }, + "GET /activities/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "activity:read", + ], + }, + "GET /admin": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/demo-user/sync": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "syncDemoUserAccount", + }, + "GET /admin/queue/job": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/queue/job/:id/execute": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/user": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/user/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /ai/prompt/:mode": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "readAiPrompt", + "scopes": [ + "portfolio:read", + ], + }, + "GET /asset-profiles": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /asset-profiles/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /asset/:dataSource/:symbol": { + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /assets/:languageCode/site.webmanifest (version neutral)": { + "guards": [], + "interceptors": [], + }, + "GET /auth/google": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('google')", + ], + "interceptors": [], + }, + "GET /auth/google/callback (version neutral)": { + "allowDuringImpersonation": true, + "guards": [ + "OAuthCallbackGuardMixin('google')", + ], + "interceptors": [], + }, + "GET /auth/oidc (version neutral)": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('oidc')", + ], + "interceptors": [], + }, + "GET /auth/oidc/callback (version neutral)": { + "allowDuringImpersonation": true, + "guards": [ + "OAuthCallbackGuardMixin('oidc')", + ], + "interceptors": [], + }, + "GET /auth/webauthn/generate-registration-options": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /benchmarks": { + "allowDuringImpersonation": true, + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /benchmarks/:dataSource/:symbol/:startDateString": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /data-providers/ghostfolio/asset-profile/:symbol": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/dividends/:symbol (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/historical/:symbol (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/lookup (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/markets": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/quotes (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/status (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /exchange-rate/:symbol/:dateString": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /export": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /health": { + "guards": [], + "interceptors": [], + }, + "GET /health/ai": { + "guards": [], + "interceptors": [], + }, + "GET /health/data-enhancer/:name": { + "guards": [], + "interceptors": [], + }, + "GET /health/data-provider/:dataSource": { + "guards": [], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /health/liveness": { + "guards": [], + "interceptors": [], + }, + "GET /import/dividends/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /info": { + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + }, + "GET /logo": { + "guards": [], + "interceptors": [], + }, + "GET /logo/:dataSource/:symbol": { + "guards": [], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /market-data/markets": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readMarketDataOfMarkets", + }, + "GET /platform": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readPlatformsWithAccountCount", + }, + "GET /platforms": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readPlatforms", + }, + "GET /portfolio/details": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/dividends": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/holding/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/holdings": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/investments": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/performance (version 2)": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "PerformanceLoggingInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/report": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [], + "scopes": [ + "portfolio:read", + ], + }, + "GET /public/:accessId/portfolio": { + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "RedactValuesInResponseInterceptor", + ], + }, + "GET /sitemap.xml (version neutral)": { + "guards": [], + "interceptors": [], + }, + "GET /subscription/stripe/callback": { + "allowDuringImpersonation": true, + "guards": [], + "interceptors": [], + }, + "GET /symbol/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /symbol/:dataSource/:symbol/:dateString": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /symbol/lookup": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + }, + "GET /tags": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readTags", + }, + "GET /user": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "RedactValuesInResponseInterceptor", + ], + }, + "GET /watchlist": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + "permission": "readWatchlist", + "scopes": [ + "watchlist:read", + ], + }, + "PATCH /admin/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "PATCH /asset-profiles/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /access": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "createAccess", + }, + "POST /account": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "createAccount", + "scopes": [ + "account:create", + ], + }, + "POST /account-balance": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "createAccountBalance", + "scopes": [ + "account:update", + ], + }, + "POST /account/transfer-balance": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [], + "permission": "updateAccount", + "scopes": [ + "account:update", + ], + }, + "POST /activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "permission": "createActivity", + "scopes": [ + "activity:create", + ], + }, + "POST /admin/gather": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/:dataSource/:symbol/:dateString": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/max": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/profile-data": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/market-data/:dataSource/:symbol/test": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "accessAdminControl", + }, + "POST /admin/profile-data/:dataSource/:symbol/merge": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "accessAdminControl", + }, + "POST /ai/activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "CustomThrottlerGuard", + ], + "interceptors": [ + "MixinInterceptor", + ], + "permission": "enableGhostfolioAi", + }, + "POST /api-keys": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "createApiKey", + }, + "POST /asset-profiles/:dataSource/:symbol/splits": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "POST /auth/anonymous": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /auth/webauthn/generate-authentication-options": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /auth/webauthn/verify-attestation": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "POST /auth/webauthn/verify-authentication": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /benchmarks": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /cache/flush": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /import": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + "permission": "createActivity", + }, + "POST /market-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [], + }, + "POST /platform": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "createPlatform", + }, + "POST /subscription/redeem-coupon": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "CustomThrottlerGuard", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "POST /subscription/stripe/checkout-session": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "CustomThrottlerGuard", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "POST /tags": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [], + }, + "POST /user": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /user/:id/access-token": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /user/access-token": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updateOwnAccessToken", + }, + "POST /watchlist": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "createWatchlistItem", + "scopes": [ + "watchlist:create", + ], + }, + "PUT /access/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updateAccess", + }, + "PUT /account/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "updateAccount", + "scopes": [ + "account:update", + ], + }, + "PUT /activities/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "permission": "updateActivity", + "scopes": [ + "activity:update", + ], + }, + "PUT /admin/settings/:key": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "PUT /platform/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updatePlatform", + }, + "PUT /portfolio/holding/:dataSource/:symbol/tags": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "updateActivity", + "scopes": [ + "activity:update", + ], + }, + "PUT /tags/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updateTag", + }, + "PUT /user/setting": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + }, +} +`; diff --git a/apps/api/src/app/app.module.spec.ts b/apps/api/src/app/app.module.spec.ts index 976e08393b..f04485ccdd 100644 --- a/apps/api/src/app/app.module.spec.ts +++ b/apps/api/src/app/app.module.spec.ts @@ -1,17 +1,26 @@ +import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; +import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; + import { DynamicModule, ForwardReference, RequestMethod, - Type + Type, + VERSION_NEUTRAL } from '@nestjs/common'; import { GUARDS_METADATA, + INTERCEPTORS_METADATA, METHOD_METADATA, MODULE_METADATA, - PATH_METADATA + PATH_METADATA, + VERSION_METADATA } from '@nestjs/common/constants'; +import { VersionValue } from '@nestjs/common/interfaces'; import { MetadataScanner } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { isUndefined, omitBy } from 'lodash-es'; import { AppModule } from './app.module'; @@ -26,6 +35,13 @@ jest.mock('ai', () => { type ModuleDefinition = DynamicModule | ForwardReference<() => Type> | Promise | Type; +/** + * The strategies of the authentication which the routes apply via + * AuthGuard(). The class of such a guard has a random name, hence the + * snapshot of the routes names it by its strategy + */ +const AUTHENTICATION_STRATEGIES = ['api-key', 'google', 'jwt', 'oidc']; + /** * The routes of the controllers which answer a request without the * authentication of a user or of an API key, sorted by the request method and @@ -98,6 +114,57 @@ async function getControllers( return [...new Set(controllers)]; } +/** + * Gives the guards or the interceptors which the decorators of the controller + * and of the route handler set, in the order in which they run + */ +function getEnhancers(key: string, controller: Type, routeHandler: object) { + return [controller, routeHandler].flatMap((target) => { + return (Reflect.getMetadata(key, target) ?? []) as Type[]; + }); +} + +/** + * Gives the metadata of the route handler or, if it has none, of the + * controller, as Reflector.getAllAndOverride() does + */ +function getMetadataOfRoute( + key: string, + controller: Type, + routeHandler: object +) { + return (Reflect.getMetadata(key, routeHandler) ?? + Reflect.getMetadata(key, controller)) as T | undefined; +} + +/** + * Gives the name of a guard or of an interceptor. The function mixin() + * replaces the name of a class by a random one, hence the name is read from + * the declaration of the class. A guard which extends AuthGuard() is named + * with its strategy + */ +function getName(enhancer: Type) { + const authenticationStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => { + return AuthGuard(strategy) === enhancer; + }); + + if (authenticationStrategy) { + return `AuthGuard('${authenticationStrategy}')`; + } + + const name = /^class\s+([\w$]+)/.exec(enhancer.toString())?.[1]; + + if (!name || name === 'MixinAuthGuard') { + throw new Error('The name of a guard or of an interceptor is unknown'); + } + + const parentStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => { + return Object.getPrototypeOf(enhancer) === AuthGuard(strategy); + }); + + return parentStrategy ? `${name}('${parentStrategy}')` : name; +} + /** * Gives the paths which the decorator of a controller or of a route sets */ @@ -108,14 +175,10 @@ function getPaths(target: object) { } /** - * Gives the routes of the controller which apply neither AuthGuard('api-key') - * nor AuthGuard('jwt'), each as the request method and the path + * Gives the routes of the controller, each as the request method and the path + * with its route handler */ -function getRoutesWithoutAuthentication(controller: Type) { - const authenticationGuards: unknown[] = [ - AuthGuard('api-key'), - AuthGuard('jwt') - ]; +function getRoutes(controller: Type) { const routeHandlersByName = controller.prototype as Record; return new MetadataScanner() @@ -131,19 +194,6 @@ function getRoutesWithoutAuthentication(controller: Type) { return []; } - const guards = [controller, routeHandler].flatMap((target) => { - return (Reflect.getMetadata(GUARDS_METADATA, target) ?? - []) as unknown[]; - }); - - const hasAuthentication = guards.some((guard) => { - return authenticationGuards.includes(guard); - }); - - if (hasAuthentication) { - return []; - } - return getPaths(controller).flatMap((controllerPath) => { return getPaths(routeHandler).map((routePath) => { const path = `${controllerPath}/${routePath}` @@ -151,12 +201,110 @@ function getRoutesWithoutAuthentication(controller: Type) { .filter(Boolean) .join('/'); - return `${RequestMethod[requestMethod]} /${path}`; + return { + routeHandler, + route: `${RequestMethod[requestMethod]} /${path}` + }; }); }); }); } +/** + * Gives the routes of the controller which apply neither AuthGuard('api-key') + * nor AuthGuard('jwt'), each as the request method and the path + */ +function getRoutesWithoutAuthentication(controller: Type) { + const authenticationGuards: unknown[] = [ + AuthGuard('api-key'), + AuthGuard('jwt') + ]; + + return getRoutes(controller) + .filter(({ routeHandler }) => { + const hasAuthentication = getEnhancers( + GUARDS_METADATA, + controller, + routeHandler + ).some((guard) => { + return authenticationGuards.includes(guard); + }); + + return !hasAuthentication; + }) + .map(({ route }) => { + return route; + }); +} + +/** + * Gives the routes of the controller, each as the request method, the path + * and the version with the guards and the interceptors in the order in which + * they run, the permission, the scopes and whether the route stays available + * while an impersonation is active + */ +function getRoutesWithSecurity(controller: Type) { + return getRoutes(controller).map(({ route, routeHandler }) => { + const security = omitBy( + { + allowDuringImpersonation: getMetadataOfRoute( + ALLOW_DURING_IMPERSONATION_KEY, + controller, + routeHandler + ), + guards: getEnhancers(GUARDS_METADATA, controller, routeHandler).map( + (guard) => { + return getName(guard); + } + ), + interceptors: getEnhancers( + INTERCEPTORS_METADATA, + controller, + routeHandler + ).map((interceptor) => { + return getName(interceptor); + }), + // HasPermissionGuard reads the permission of the route handler only + permission: Reflect.getMetadata(HAS_PERMISSION_KEY, routeHandler) as + string | undefined, + scopes: getMetadataOfRoute( + REQUIRES_SCOPE_KEY, + controller, + routeHandler + ) + }, + isUndefined + ); + + return [ + `${route}${getVersionSuffix(controller, routeHandler)}`, + security + ] as const; + }); +} + +/** + * Gives the version of the route as a suffix of its key, which is empty for + * the default version + */ +function getVersionSuffix(controller: Type, routeHandler: object) { + const version = getMetadataOfRoute( + VERSION_METADATA, + controller, + routeHandler + ); + + if (version === undefined) { + return ''; + } + + const versions = [version].flat().map((value) => { + return value === VERSION_NEUTRAL ? 'neutral' : value; + }); + + return ` (version ${versions.join(', ')})`; +} + describe('AppModule', () => { it('should require the authentication for each route which is not public', async () => { const controllers = await getControllers(AppModule); @@ -169,4 +317,22 @@ describe('AppModule', () => { expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES); }); + + // An intended change of a route is confirmed by an update of the snapshot + // (--updateSnapshot), hence each change shows in the review + it('should keep the security of each route', async () => { + const controllers = await getControllers(AppModule); + + const routesWithSecurity = controllers.flatMap((controller) => { + return getRoutesWithSecurity(controller); + }); + + const routes = routesWithSecurity.map(([route]) => { + return route; + }); + + // The snapshot is an object, which would hide a route declared twice + expect(new Set(routes).size).toBe(routes.length); + expect(Object.fromEntries(routesWithSecurity)).toMatchSnapshot(); + }); }); From 6157d2bd53932d049c0afbd6960fe8d87a496e83 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:02:52 +0200 Subject: [PATCH 04/11] Task/remove unused user in historical market data editor (#8243) Remove unused user --- .../asset-profile-dialog/asset-profile-dialog.html | 1 - .../holding-detail-dialog/holding-detail-dialog.html | 1 - .../interfaces/interfaces.ts | 2 -- .../historical-market-data-editor.component.ts | 6 ++---- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index 0b9e038a02..8e6341a709 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -505,7 +505,6 @@ [locale]="data.locale" [marketData]="marketDataItems" [symbol]="data.symbol" - [user]="user" (marketDataChanged)="onMarketDataChanged($event)" />
diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index efb693e59a..de56a7b003 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -437,7 +437,6 @@ [locale]="data.locale" [marketData]="marketDataItems" [symbol]="assetProfile?.symbol" - [user]="user" (marketDataChanged)="onMarketDataChanged($event)" /> diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts index 4a0e419e7b..4803071ac7 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts @@ -1,4 +1,3 @@ -import { User } from '@ghostfolio/common/interfaces'; import { DataSource } from '@ghostfolio/prisma/enums'; export interface HistoricalMarketDataEditorDialogParams { @@ -7,5 +6,4 @@ export interface HistoricalMarketDataEditorDialogParams { dateString: string; marketPrice?: number; symbol: string; - user: User; } diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts index 94baafbf1a..cefc96b8b2 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts @@ -4,7 +4,7 @@ import { getDateFormatString, getLocale } from '@ghostfolio/common/helper'; -import { LineChartItem, User } from '@ghostfolio/common/interfaces'; +import { LineChartItem } from '@ghostfolio/common/interfaces'; import type { MarketData } from '@ghostfolio/prisma/browser'; import { DataSource } from '@ghostfolio/prisma/enums'; import { DataService } from '@ghostfolio/ui/services'; @@ -70,7 +70,6 @@ export class GfHistoricalMarketDataEditorComponent @Input() dataSource: DataSource; @Input() dateOfFirstActivity?: Date; @Input() symbol: string; - @Input() user: User; @Output() marketDataChanged = new EventEmitter(); @@ -233,8 +232,7 @@ export class GfHistoricalMarketDataEditorComponent currency: this.currency, dataSource: this.dataSource, dateString: `${yearMonth}-${day}`, - symbol: this.symbol, - user: this.user + symbol: this.symbol }, height: this.deviceType() === 'mobile' ? '98vh' : '80vh', width: this.deviceType() === 'mobile' ? '100vw' : '50rem' From 62b1ea937fdcd2775ebd66e2af3980ef9a9b8948 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:25:12 +0200 Subject: [PATCH 05/11] Task/harmonize pointer events override of ion icon (#8254) Harmonize pointer events override --- .../portfolio-performance.component.html | 1 + .../portfolio-performance.component.scss | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 9be2be344e..c458080e3c 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -3,6 +3,7 @@
@if ((errors()?.length ?? 0) > 0 && !isLoading()) { Date: Tue, 20 Oct 2026 19:26:21 +0200 Subject: [PATCH 06/11] Task/improve loading state of file drop area component (#8250) * Improve loading state * Update changelog --- CHANGELOG.md | 1 + .../file-drop-area.component.html | 13 +-- .../file-drop-area.component.scss | 94 ++++++++++++++++--- .../file-drop-area.component.stories.ts | 6 -- .../file-drop-area.component.ts | 3 +- 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73207ee4ce..11cae3d5e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Improved the usability of the activities page by eliminating the reload on canceling the import activities dialog +- Improved the loading state of the file drop area component - Improved the accessibility of the _Buy me a coffee_ button on the about page - Improved the accessibility of the entity logo component - Extracted the file drop area of the import activities dialog to a reusable component diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.html b/libs/ui/src/lib/file-drop-area/file-drop-area.component.html index b5c6e19021..a4d1d6c91e 100644 --- a/libs/ui/src/lib/file-drop-area/file-drop-area.component.html +++ b/libs/ui/src/lib/file-drop-area/file-drop-area.component.html @@ -1,13 +1,14 @@ @if (isLoading()) { -
- - {{ loadingText() }} +
+
+ {{ loadingText() }} +
} @else {