diff --git a/CHANGELOG.md b/CHANGELOG.md index 191bacd021..aee61cce8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed the deprecated `isDraft` attribute of the activity in favor of the _Draft_ tag +## 3.48.1 - 2026-08-11 + +### Added + +- Added the quantity to the accounts tab of the holding detail dialog (experimental) + +### Changed + +- Improved the performance of the _X-ray_ page by resolving the user only once per request +- Refactored the impersonation mode to resolve the impersonated user once per request in a guard instead of in every endpoint +- Restricted the modification of data in impersonation mode to the data of the authenticated user +- Restricted the update of the user settings in impersonation mode to the settings of the authenticated user + +### Fixed + +- Fixed the allocation in the accounts tab of the holding detail dialog caused by floating-point rounding +- Fixed the allocation in the accounts tab of the holding detail dialog by excluding the cash balance of the account +- Fixed the account aggregations in impersonation mode to be based on the impersonated user +- Fixed the base currency of the activities in impersonation mode to be based on the impersonated user +- Fixed the base currency of the dividends in impersonation mode to be based on the impersonated user +- Fixed the base currency of the user account settings in impersonation mode to be disabled +- Fixed the benchmark of the performance chart in impersonation mode to be based on the authenticated user +- Fixed the emergency fund of the _X-ray_ page in impersonation mode to be based on the impersonated user +- Fixed the redaction of the emergency fund, projected total amount and savings rate in a restricted view +- Fixed the rules of the _X-ray_ page to be withheld in a restricted view +- Fixed the savings rate of the _FIRE_ calculator in impersonation mode to be presented +- Fixed the user settings in impersonation mode to be based on the impersonated user +- Fixed the validation of the impersonation identifier of an unknown user + +## 3.47.0 - 2026-08-10 + +### Changed + +- Extended the toggle component to support a disabled state +- Extended the toggle component to support icons +- Reused the toggle component on the portfolio holdings page +- Reused the currency selector component in the user account settings + +### Fixed + +- Fixed the handling of the disabled state in the currency selector and symbol autocomplete components +- Fixed the restoration of the current selection in the currency selector component when leaving the field without picking an option + ## 3.46.0 - 2026-08-09 ### Added diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 3bad0e171e..54fadec68b 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -26,6 +27,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AccessService } from './access.service'; +@AllowDuringImpersonation() @Controller('access') export class AccessController { public constructor( diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts index f43aeedd57..ffb8ec6ab9 100644 --- a/apps/api/src/app/account/account.controller.ts +++ b/apps/api/src/app/account/account.controller.ts @@ -1,13 +1,12 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { CreateAccountDto, TransferBalanceDto, @@ -19,14 +18,16 @@ import { AccountsResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, Delete, Get, - Headers, HttpException, Inject, Param, @@ -49,10 +50,8 @@ export class AccountController { private readonly accountBalanceService: AccountBalanceService, private readonly accountService: AccountService, private readonly apiService: ApiService, - private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser, - private readonly userService: UserService + @Inject(REQUEST) private readonly request: RequestWithUser ) {} @Delete(':id') @@ -85,18 +84,15 @@ export class AccountController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getAllAccounts( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId }: ImpersonationContext, @Query('dataSource') filterByDataSource?: string, @Query('query') filterBySearchQuery?: string, @Query('symbol') filterBySymbol?: string ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const filters = this.apiService.buildFiltersFromQueryParams({ filterByDataSource, filterBySearchQuery, @@ -105,25 +101,22 @@ export class AccountController { return this.portfolioService.getAccountsWithAggregations({ filters, - userId: impersonationUserId || this.request.user.id, + userId, withExcludedAccounts: true }); } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountById( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId }: ImpersonationContext, @Param('id') id: string ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const accountsWithAggregations = await this.portfolioService.getAccountsWithAggregations({ + userId, filters: [{ id, type: 'ACCOUNT' }], - userId: impersonationUserId || this.request.user.id, withExcludedAccounts: true }); @@ -131,22 +124,16 @@ export class AccountController { } @Get(':id/balances') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(RedactValuesInResponseInterceptor) public async getAccountBalancesById( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId, userSettings }: ImpersonationContext, @Param('id') id: string ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const userId = impersonationUserId || this.request.user.id; - - const { settings } = await this.userService.user({ id: userId }); - return this.accountBalanceService.getAccountBalances({ userId, filters: [{ id, type: 'ACCOUNT' }], - userCurrency: settings.settings.baseCurrency + userCurrency: userSettings.baseCurrency }); } diff --git a/apps/api/src/app/activities/activities.controller.ts b/apps/api/src/app/activities/activities.controller.ts index 72056737cb..aa44857ad2 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -1,32 +1,32 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { - DATA_GATHERING_QUEUE_PRIORITY_HIGH, - HEADER_KEY_IMPERSONATION -} from '@ghostfolio/common/config'; +import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { ActivitiesResponse, ActivityResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, Delete, Get, - Headers, HttpException, Inject, Param, @@ -53,7 +53,6 @@ export class ActivitiesController { private readonly apiService: ApiService, private readonly dataProviderService: DataProviderService, private readonly dataGatheringService: DataGatheringService, - private readonly impersonationService: ImpersonationService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -62,7 +61,6 @@ export class ActivitiesController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async deleteActivities( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query() { accounts, @@ -74,13 +72,6 @@ export class ActivitiesController { tags }: ActivitiesFilterDto ): Promise { - if (impersonationId) { - throw new HttpException( - getReasonPhrase(StatusCodes.FORBIDDEN), - StatusCodes.FORBIDDEN - ); - } - let endDate: Date; let startDate: Date; @@ -129,12 +120,12 @@ export class ActivitiesController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getAllActivities( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId, userSettings }: ImpersonationContext, @Query() { accounts, @@ -167,11 +158,6 @@ export class ActivitiesController { filterByTags: tags }); - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - - const userCurrency = this.request.user.settings.settings.baseCurrency; - const { activities, count } = await this.activitiesService.getActivities({ endDate, filters, @@ -180,10 +166,10 @@ export class ActivitiesController { sortDirection, startDate, take, - userCurrency, + userId, includeDrafts: true, types: activityTypes, - userId: impersonationUserId || this.request.user.id, + userCurrency: userSettings.baseCurrency, withExcludedAccountsAndActivities: true }); @@ -191,21 +177,17 @@ export class ActivitiesController { } @Get(':id') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getActivityById( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId, userSettings }: ImpersonationContext, @Param('id') id: string ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const userCurrency = this.request.user.settings.settings.baseCurrency; - const { activities } = await this.activitiesService.getActivities({ - userCurrency, + userId, includeDrafts: true, - userId: impersonationUserId || this.request.user.id, + userCurrency: userSettings.baseCurrency, withExcludedAccountsAndActivities: true }); diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 9009ded549..8653191b96 100644 --- a/apps/api/src/app/admin/admin.controller.ts +++ b/apps/api/src/app/admin/admin.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; @@ -61,6 +62,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AdminService } from './admin.service'; import { PropertyKeyPipe } from './pipes/property-key.pipe'; +@AllowDuringImpersonation() @Controller('admin') export class AdminController { private readonly logger = new Logger(AdminController.name); diff --git a/apps/api/src/app/admin/queue/queue.controller.ts b/apps/api/src/app/admin/queue/queue.controller.ts index 060abd2474..71eedd16ee 100644 --- a/apps/api/src/app/admin/queue/queue.controller.ts +++ b/apps/api/src/app/admin/queue/queue.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { AdminJobs } from '@ghostfolio/common/interfaces'; @@ -16,6 +17,7 @@ import { JobStatus } from 'bull'; import { QueueService } from './queue.service'; +@AllowDuringImpersonation() @Controller('admin/queue') export class QueueController { public constructor(private readonly queueService: QueueService) {} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index ddda044a79..e79abca505 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -1,5 +1,6 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter'; +import { ImpersonationWriteGuard } from '@ghostfolio/api/guards/impersonation-write.guard'; import { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; @@ -25,7 +26,7 @@ import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis' import { BullModule } from '@nestjs/bull'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { APP_FILTER } from '@nestjs/core'; +import { APP_FILTER, APP_GUARD } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; @@ -191,6 +192,10 @@ import { UserModule } from './user/user.module'; { provide: APP_FILTER, useClass: PortfolioSnapshotComputationExceptionFilter + }, + { + provide: APP_GUARD, + useClass: ImpersonationWriteGuard } ] }) diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/auth-device/auth-device.controller.ts index c46589d74c..45f7925ad6 100644 --- a/apps/api/src/app/auth-device/auth-device.controller.ts +++ b/apps/api/src/app/auth-device/auth-device.controller.ts @@ -1,4 +1,5 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { permissions } from '@ghostfolio/common/permissions'; @@ -16,6 +17,7 @@ import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +@AllowDuringImpersonation() @Controller('auth-device') export class AuthDeviceController { public constructor( diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index e3886e39c3..ccf79ba99a 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -27,6 +28,7 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { AuthService } from './auth.service'; +@AllowDuringImpersonation() @Controller('auth') export class AuthController { public constructor( diff --git a/apps/api/src/app/cache/cache.controller.ts b/apps/api/src/app/cache/cache.controller.ts index 4d34a2effa..680e7e6825 100644 --- a/apps/api/src/app/cache/cache.controller.ts +++ b/apps/api/src/app/cache/cache.controller.ts @@ -1,4 +1,5 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { permissions } from '@ghostfolio/common/permissions'; @@ -6,6 +7,7 @@ import { permissions } from '@ghostfolio/common/permissions'; import { Controller, Post, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +@AllowDuringImpersonation() @Controller('cache') export class CacheController { public constructor(private readonly redisCacheService: RedisCacheService) {} diff --git a/apps/api/src/app/endpoints/ai/ai.controller.ts b/apps/api/src/app/endpoints/ai/ai.controller.ts index 6c8102db10..1a3fd3ba2a 100644 --- a/apps/api/src/app/endpoints/ai/ai.controller.ts +++ b/apps/api/src/app/endpoints/ai/ai.controller.ts @@ -46,7 +46,6 @@ export class AiController { const prompt = await this.aiService.getPrompt({ filters, mode, - impersonationId: undefined, languageCode: this.request.user.settings.settings.language, userCurrency: this.request.user.settings.settings.baseCurrency, userId: this.request.user.id diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index d0ef178442..70741374dc 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -70,14 +70,12 @@ export class AiService { public async getPrompt({ filters, - impersonationId, languageCode, mode, userCurrency, userId }: { filters?: Filter[]; - impersonationId: string; languageCode: string; mode: AiPromptMode; userCurrency: string; @@ -85,7 +83,6 @@ export class AiService { }) { const { holdings } = await this.portfolioService.getDetails({ filters, - impersonationId, userId }); diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts index cbc68df93d..cde2ade8cd 100644 --- a/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts +++ b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; @@ -9,6 +10,7 @@ import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +@AllowDuringImpersonation() @Controller('api-keys') export class ApiKeysController { public constructor( diff --git a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts index 5ffb756a0d..356544acb4 100644 --- a/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts +++ b/apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; @@ -41,6 +42,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AssetProfilesService } from './asset-profiles.service'; +@AllowDuringImpersonation() @Controller('asset-profiles') export class AssetProfilesController { public constructor( diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts index 53df9bd92f..79949a7159 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -1,34 +1,33 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import type { AssetProfileIdentifier, BenchmarkMarketDataDetailsResponse, BenchmarkResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { ImpersonationContext } from '@ghostfolio/common/types'; import { Body, Controller, Delete, Get, - Headers, HttpException, - Inject, Param, Post, Query, UseGuards, UseInterceptors } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { DataSource } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -36,13 +35,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { BenchmarksService } from './benchmarks.service'; import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto'; +@AllowDuringImpersonation() @Controller('benchmarks') export class BenchmarksController { public constructor( private readonly apiService: ApiService, private readonly benchmarkService: BenchmarkService, - private readonly benchmarksService: BenchmarksService, - @Inject(REQUEST) private readonly request: RequestWithUser + private readonly benchmarksService: BenchmarksService ) {} @HasPermission(permissions.accessAdminControl) @@ -112,10 +111,10 @@ export class BenchmarksController { } @Get(':dataSource/:symbol/:startDateString') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getBenchmarkMarketDataForUser( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId, userSettings }: ImpersonationContext, @Param('dataSource') dataSource: DataSource, @Param('startDateString') startDateString: string, @Param('symbol') symbol: string, @@ -147,12 +146,12 @@ export class BenchmarksController { dataSource, endDate, filters, - impersonationId, startDate, symbol, + userId, + userSettings, withExcludedAccounts, - dateRange: range, - user: this.request.user + dateRange: range }); } } diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts index 1fe42ab0df..4b6a3f1c9d 100644 --- a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -7,9 +7,10 @@ import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, BenchmarkMarketDataDetailsResponse, - Filter + Filter, + UserSettings } from '@ghostfolio/common/interfaces'; -import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; +import { DateRange } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { format, isSameDay } from 'date-fns'; @@ -32,28 +33,26 @@ export class BenchmarksService { dateRange, endDate = new Date(), filters, - impersonationId, startDate, symbol, - user, + userId, + userSettings, withExcludedAccounts }: { dateRange: DateRange; endDate?: Date; filters?: Filter[]; - impersonationId: string; startDate: Date; - user: UserWithSettings; + userId: string; + userSettings: UserSettings; withExcludedAccounts?: boolean; } & AssetProfileIdentifier): Promise { const marketData: { date: string; value: number }[] = []; - const userCurrency = user.settings.settings.baseCurrency; - const userId = user.id; + const userCurrency = userSettings.baseCurrency; const { chart } = await this.portfolioService.getPerformance({ dateRange, filters, - impersonationId, userId, withExcludedAccounts }); diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts index 03d50c2841..6f7583acf3 100644 --- a/apps/api/src/app/endpoints/market-data/market-data.controller.ts +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -1,4 +1,5 @@ import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; @@ -27,6 +28,7 @@ import { DataSource, Prisma } from '@prisma/client'; import { parseISO } from 'date-fns'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; +@AllowDuringImpersonation() @Controller('market-data') export class MarketDataController { public constructor( diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index 67bed71ef3..6093e5f872 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -78,7 +78,6 @@ export class PublicController { ] = await Promise.all([ this.portfolioService.getDetails({ filters, - impersonationId: access.userId, userId: user.id, withMarkets: true }), @@ -86,7 +85,6 @@ export class PublicController { return this.portfolioService.getPerformance({ dateRange, filters, - impersonationId: undefined, userId: user.id }); }) diff --git a/apps/api/src/app/endpoints/tags/tags.controller.ts b/apps/api/src/app/endpoints/tags/tags.controller.ts index cd043b5939..a61e1188c8 100644 --- a/apps/api/src/app/endpoints/tags/tags.controller.ts +++ b/apps/api/src/app/endpoints/tags/tags.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { TagService } from '@ghostfolio/api/services/tag/tag.service'; @@ -23,6 +24,7 @@ import { AuthGuard } from '@nestjs/passport'; import { Tag } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +@AllowDuringImpersonation() @Controller('tags') export class TagsController { public constructor( diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts index 78693239aa..947612cb18 100644 --- a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -1,20 +1,22 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; -import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos'; import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; -import { RequestWithUser } from '@ghostfolio/common/types'; +import { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, Delete, Get, - Headers, HttpException, Inject, Param, @@ -32,7 +34,6 @@ import { WatchlistService } from './watchlist.service'; @Controller('watchlist') export class WatchlistController { public constructor( - private readonly impersonationService: ImpersonationService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly watchlistService: WatchlistService ) {} @@ -81,17 +82,12 @@ export class WatchlistController { @Get() @HasPermission(permissions.readWatchlist) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getWatchlistItems( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + @Impersonation() { userId }: ImpersonationContext ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - - const watchlist = await this.watchlistService.getWatchlistItems( - impersonationUserId || this.request.user.id - ); + const watchlist = await this.watchlistService.getWatchlistItems(userId); return { watchlist diff --git a/apps/api/src/app/platform/platform.controller.ts b/apps/api/src/app/platform/platform.controller.ts index ebf03e3a9f..6c0af45158 100644 --- a/apps/api/src/app/platform/platform.controller.ts +++ b/apps/api/src/app/platform/platform.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; @@ -20,6 +21,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { PlatformService } from './platform.service'; +@AllowDuringImpersonation() @Controller('platform') export class PlatformController { public constructor(private readonly platformService: PlatformService) {} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 953976a4aa..2edffc8d3c 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,7 +1,8 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { hasNotDefinedValuesInObject, nullifyValuesInObject @@ -12,12 +13,8 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { - HEADER_KEY_IMPERSONATION, - UNKNOWN_KEY -} from '@ghostfolio/common/config'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; import { PortfolioDetails, @@ -33,13 +30,15 @@ import { isRestrictedView, permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, Controller, Get, - Headers, HttpException, Inject, Param, @@ -69,19 +68,17 @@ export class PortfolioController { private readonly activitiesService: ActivitiesService, private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, - private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService, - @Inject(REQUEST) private readonly request: RequestWithUser, - private readonly userService: UserService + @Inject(REQUEST) private readonly request: RequestWithUser ) {} @Get('details') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getDetails( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { accessId, userId }: ImpersonationContext, @Query() { accounts: filterByAccounts, @@ -120,10 +117,9 @@ export class PortfolioController { summary } = await this.portfolioService.getDetails({ filters, - impersonationId, + userId, withMarkets, dateRange: range, - userId: this.request.user.id, withSummary: true }); @@ -135,8 +131,8 @@ export class PortfolioController { if ( hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet + accesses: this.request.user?.accessesGet, + impersonationId: accessId }) || isRestrictedView(this.request.user) ) { @@ -179,8 +175,8 @@ export class PortfolioController { if ( hasDetails === false || hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet + accesses: this.request.user?.accessesGet, + impersonationId: accessId }) || isRestrictedView(this.request.user) ) { @@ -323,10 +319,10 @@ export class PortfolioController { } @Get('dividends') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getDividends( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { accessId, userId, userSettings }: ImpersonationContext, @Query() { accounts, @@ -346,12 +342,7 @@ export class PortfolioController { filterByTags: tags }); - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const userId = impersonationUserId || this.request.user.id; - - const { settings } = await this.userService.user({ id: userId }); - const userCurrency = settings.settings.baseCurrency; + const userCurrency = userSettings.baseCurrency; const { endDate, startDate } = getIntervalFromDateRange({ dateRange: range @@ -368,13 +359,14 @@ export class PortfolioController { let dividends = this.portfolioService.getDividends({ activities, - groupBy + groupBy, + userCurrency }); if ( hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet + accesses: this.request.user?.accessesGet, + impersonationId: accessId }) || isRestrictedView(this.request.user) ) { @@ -405,17 +397,16 @@ export class PortfolioController { @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) public async getHolding( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId }: ImpersonationContext, @Param('dataSource') dataSource: DataSource, @Param('symbol') symbol: string ): Promise { const holding = await this.portfolioService.getHolding({ dataSource, - impersonationId, symbol, - userId: this.request.user.id + userId }); if (!holding) { @@ -429,12 +420,12 @@ export class PortfolioController { } @Get('holdings') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getHoldings( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { userId }: ImpersonationContext, @Query() { accounts, @@ -459,19 +450,18 @@ export class PortfolioController { const holdings = await this.portfolioService.getHoldings({ filters, - impersonationId, - dateRange: range, - userId: this.request.user.id + userId, + dateRange: range }); return { holdings }; } @Get('investments') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async getInvestments( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { accessId, userId }: ImpersonationContext, @Query() { accounts, @@ -495,15 +485,14 @@ export class PortfolioController { await this.portfolioService.getInvestments({ filters, groupBy, - impersonationId, - dateRange: range, - userId: this.request.user.id + userId, + dateRange: range }); if ( hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet + accesses: this.request.user?.accessesGet, + impersonationId: accessId }) || isRestrictedView(this.request.user) ) { @@ -543,13 +532,13 @@ export class PortfolioController { } @Get('performance') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) @Version('2') public async getPerformanceV2( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Impersonation() { accessId, userId }: ImpersonationContext, @Query() { accounts, @@ -571,16 +560,15 @@ export class PortfolioController { const performanceInformation = await this.portfolioService.getPerformance({ filters, - impersonationId, + userId, withExcludedAccounts, - dateRange: range, - userId: this.request.user.id + dateRange: range }); if ( hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet + accesses: this.request.user?.accessesGet, + impersonationId: accessId }) || isRestrictedView(this.request.user) || this.request.user.settings.settings.viewMode === 'ZEN' @@ -657,18 +645,20 @@ export class PortfolioController { } @Get('report') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) public async getReport( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + @Impersonation() { accessId, userId }: ImpersonationContext ): Promise { - const report = await this.portfolioService.getReport({ - impersonationId, - userId: this.request.user.id - }); + const report = await this.portfolioService.getReport({ userId }); if ( - this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && - this.request.user.subscription?.type === SubscriptionType.Basic + hasReadRestrictedAccessPermission({ + accesses: this.request.user?.accessesGet, + impersonationId: accessId + }) || + isRestrictedView(this.request.user) || + (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription?.type === SubscriptionType.Basic) ) { for (const category of report.xRay.categories) { category.rules = null; @@ -686,7 +676,7 @@ export class PortfolioController { @HasPermission(permissions.updateActivity) @Put('holding/:dataSource/:symbol/tags') @UseInterceptors(TransformDataSourceInRequestInterceptor) - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) public async updateHoldingTags( @Body() data: UpdateHoldingTagsDto, @Param('dataSource') dataSource: DataSource, diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index eed3a27cb7..9b6bacd444 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -8,7 +8,6 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; @@ -30,7 +29,6 @@ describe('PortfolioService', () => { let configurationService: ConfigurationService; let dataProviderService: DataProviderService; let exchangeRateDataService: ExchangeRateDataService; - let impersonationService: ImpersonationService; let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioService: PortfolioService; let symbolProfileService: SymbolProfileService; @@ -77,8 +75,6 @@ describe('PortfolioService', () => { null ); - impersonationService = new ImpersonationService(null, null); - portfolioCalculatorFactory = new PortfolioCalculatorFactory( configurationService, null, @@ -110,7 +106,6 @@ describe('PortfolioService', () => { dataProviderService, exchangeRateDataService, null, - impersonationService, null, null, symbolProfileService, @@ -245,10 +240,6 @@ describe('PortfolioService', () => { .spyOn(dataProviderService, 'getDataSourceForExchangeRates') .mockReturnValue(DataSource.YAHOO); - jest - .spyOn(impersonationService, 'validateImpersonationId') - .mockResolvedValue(null); - jest .spyOn(symbolProfileService, 'getSymbolProfiles') .mockResolvedValue([]); @@ -331,7 +322,6 @@ describe('PortfolioService', () => { const { holdings } = await portfolioService.getDetails({ filters: [], - impersonationId: userDummyData.id, userId: userDummyData.id }); @@ -371,10 +361,6 @@ describe('PortfolioService', () => { .spyOn(activitiesService, 'getActivities') .mockResolvedValue({ activities: [], count: 0 }); - jest - .spyOn(impersonationService, 'validateImpersonationId') - .mockResolvedValue(null); - jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ performance: { currentValueInBaseCurrency: 3000, @@ -408,7 +394,6 @@ describe('PortfolioService', () => { balanceInBaseCurrency: 1000, emergencyFundHoldingsValueInBaseCurrency: 0, filteredValueInBaseCurrency: new Big(3000), - impersonationId: undefined, userCurrency: 'CHF', userId: userDummyData.id }); @@ -426,7 +411,10 @@ describe('PortfolioService', () => { return ( portfolioService as unknown as { getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ - accounts: Record; + accounts: Record< + string, + { quantity?: number; valueInBaseCurrency: number } + >; platforms: Record; }>; } @@ -443,6 +431,10 @@ describe('PortfolioService', () => { }; beforeEach(() => { + jest + .spyOn(accountService, 'accounts') + .mockResolvedValue([account] as unknown as AccountWithBalance[]); + jest .spyOn(accountService, 'getAccounts') .mockResolvedValue([account] as unknown as AccountWithBalance[]); @@ -510,5 +502,156 @@ describe('PortfolioService', () => { expect(accounts[UNKNOWN_KEY]).toBeUndefined(); expect(platforms[UNKNOWN_KEY]).toBeUndefined(); }); + + it('should not accumulate rounding errors of activities cancelling each other out', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.1, + type: 'BUY' + }, + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.2, + type: 'BUY' + }, + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.3, + type: 'SELL' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 1234.5678 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 0 (activities) + expect(accounts[account.id].valueInBaseCurrency).toBe(100); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(100); + }); + + it('should aggregate the quantity per account if the activities are filtered by a single holding', async () => { + const { accounts } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.1, + type: 'BUY' + }, + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.2, + type: 'BUY' + } + ], + filters: [{ id: 'AAPL', type: 'SYMBOL' }], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[account.id].quantity).toBe(0.3); + }); + + it('should not expose a quantity if the activities are not filtered by a single holding', async () => { + const { accounts } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[account.id].quantity).toBeUndefined(); + }); + + it('should only consider accounts of the current user if the activities are filtered by a single account', async () => { + const accountsSpy = jest.spyOn(accountService, 'accounts'); + + await getValueOfAccountsAndPlatforms({ + activities: [], + filters: [{ id: account.id, type: 'ACCOUNT' }], + portfolioItemsNow: {}, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accountsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: userDummyData.id, id: account.id } + }) + ); + }); + + it('should exclude the cash balance if the activities are filtered by a single holding', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 1, + type: 'BUY' + } + ], + filters: [{ id: 'AAPL', type: 'SYMBOL' }], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 1 * 10 (activity), without the balance of 100 + expect(accounts[account.id].valueInBaseCurrency).toBe(10); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(10); + }); + + it('should not accumulate rounding errors of the balances of accounts sharing a platform', async () => { + const platformId = randomUUID(); + + jest.spyOn(accountService, 'getAccounts').mockResolvedValue([ + { ...account, platformId, balance: 0.1, id: randomUUID() }, + { ...account, platformId, balance: 0.2, id: randomUUID() } + ] as unknown as AccountWithBalance[]); + + const { platforms } = await getValueOfAccountsAndPlatforms({ + activities: [], + filters: [], + portfolioItemsNow: {}, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 0.1 (balance) + 0.2 (balance) + expect(platforms[platformId].valueInBaseCurrency).toBe(0.3); + }); }); }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 704de93f25..2cbafc0765 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -25,7 +25,6 @@ import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.s import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { getAnnualizedPerformancePercent, @@ -45,7 +44,8 @@ import { getSum, isAccountExcluded, isDraftActivity, - parseDate + parseDate, + resolveUserSettings } from '@ghostfolio/common/helper'; import { AccountsResponse, @@ -125,7 +125,6 @@ export class PortfolioService { private readonly dataProviderService: DataProviderService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly i18nService: I18nService, - private readonly impersonationService: ImpersonationService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly rulesService: RulesService, private readonly symbolProfileService: SymbolProfileService, @@ -195,10 +194,9 @@ export class PortfolioService { orderBy: { name: 'asc' } }), this.getDetails({ + userId, withExcludedAccounts, - filters: filtersWithoutSearchQueryFilter, - impersonationId: userId, - userId: this.request.user.id + filters: filtersWithoutSearchQueryFilter }), this.userService.user({ id: userId }) ]); @@ -248,6 +246,10 @@ export class PortfolioService { } } + const quantityOfHolding = filterBySymbol + ? (details.accounts[account.id]?.quantity ?? 0) + : undefined; + const valueInBaseCurrency = details.accounts[account.id]?.valueInBaseCurrency ?? 0; @@ -263,6 +265,7 @@ export class PortfolioService { account.currency, userCurrency ), + quantity: quantityOfHolding, value: this.exchangeRateDataService.toCurrency( valueInBaseCurrency, userCurrency, @@ -355,10 +358,12 @@ export class PortfolioService { public getDividends({ activities, - groupBy + groupBy, + userCurrency }: { activities: Activity[]; groupBy?: GroupBy; + userCurrency: string; }): InvestmentItem[] { let dividends = activities.map(({ currency, date, value }) => { return { @@ -366,7 +371,7 @@ export class PortfolioService { investment: this.exchangeRateDataService.toCurrency( value, currency, - this.getUserCurrency() + userCurrency ) }; }); @@ -381,16 +386,12 @@ export class PortfolioService { public async getHoldings({ dateRange, filters, - impersonationId, userId }: { dateRange: DateRange; filters?: Filter[]; - impersonationId: string; userId: string; }) { - userId = await this.getUserId(impersonationId, userId); - const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( filters, ({ type }) => { @@ -404,7 +405,6 @@ export class PortfolioService { const { holdings: holdingsMap } = await this.getDetails({ dateRange, - impersonationId, userId, filters: filtersWithoutSearchQueryFilter }); @@ -429,16 +429,13 @@ export class PortfolioService { dateRange, filters, groupBy, - impersonationId, userId }: { dateRange: DateRange; filters?: Filter[]; groupBy?: GroupBy; - impersonationId: string; userId: string; }): Promise { - userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate; @@ -509,7 +506,7 @@ export class PortfolioService { public async getDetails({ dateRange = DEFAULT_DATE_RANGE, filters, - impersonationId, + user: userFromCaller, userId, withExcludedAccounts = false, withMarkets = false, @@ -517,14 +514,14 @@ export class PortfolioService { }: { dateRange?: DateRange; filters?: Filter[]; - impersonationId: string; + user?: UserWithSettings; userId: string; withExcludedAccounts?: boolean; withMarkets?: boolean; withSummary?: boolean; }): Promise { - userId = await this.getUserId(impersonationId, userId); - const user = await this.userService.user({ id: userId }); + const user = + userFromCaller ?? (await this.userService.user({ id: userId })); const userCurrency = this.getUserCurrency(user); const emergencyFund = new Big( @@ -758,7 +755,6 @@ export class PortfolioService { if (withSummary) { summary = await this.getSummary({ filteredValueInBaseCurrency, - impersonationId, portfolioCalculator, userCurrency, userId, @@ -784,14 +780,11 @@ export class PortfolioService { public async getHolding({ dataSource, - impersonationId, symbol, userId }: { - impersonationId?: string; userId: string; } & AssetProfileIdentifier): Promise { - userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); @@ -1033,16 +1026,13 @@ export class PortfolioService { public async getPerformance({ dateRange = DEFAULT_DATE_RANGE, filters, - impersonationId, userId }: { dateRange?: DateRange; filters?: Filter[]; - impersonationId: string; userId: string; withExcludedAccounts?: boolean; }): Promise { - userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const userCurrency = this.getUserCurrency(user); @@ -1135,18 +1125,23 @@ export class PortfolioService { } public async getReport({ - impersonationId, userId }: { - impersonationId: string; userId: string; }): Promise { - userId = await this.getUserId(impersonationId, userId); - const userSettings = this.request.user.settings.settings as UserSettings; + const user = await this.userService.user({ id: userId }); + + // The rules are evaluated against the portfolio of the (potentially + // impersonated) user, while the translations follow the language of the + // authenticated user + const userSettings = resolveUserSettings({ + impersonationUserSettings: user?.settings?.settings as UserSettings, + userSettings: this.request.user.settings.settings as UserSettings + }); const { accounts, holdings, markets, marketsAdvanced, summary } = await this.getDetails({ - impersonationId, + user, userId, withMarkets: true, withSummary: true @@ -1908,7 +1903,6 @@ export class PortfolioService { balanceInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency, filteredValueInBaseCurrency, - impersonationId, portfolioCalculator, userCurrency, userId @@ -1916,12 +1910,10 @@ export class PortfolioService { balanceInBaseCurrency: number; emergencyFundHoldingsValueInBaseCurrency: number; filteredValueInBaseCurrency: Big; - impersonationId: string; portfolioCalculator: PortfolioCalculator; userCurrency: string; userId: string; }): Promise { - userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); const { activities } = await this.activitiesService.getActivities({ @@ -1954,7 +1946,6 @@ export class PortfolioService { } = await portfolioCalculator.getSnapshot(); const { performance } = await this.getPerformance({ - impersonationId, userId }); @@ -2148,18 +2139,7 @@ export class PortfolioService { } private getUserCurrency(aUser?: UserWithSettings) { - return ( - aUser?.settings?.settings.baseCurrency ?? - this.request.user?.settings?.settings.baseCurrency ?? - DEFAULT_CURRENCY - ); - } - - private async getUserId(aImpersonationId: string, aUserId: string) { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(aImpersonationId); - - return impersonationUserId || aUserId; + return aUser?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; } private getUserPerformanceCalculationType( @@ -2186,6 +2166,10 @@ export class PortfolioService { const accounts: PortfolioDetails['accounts'] = {}; const platforms: PortfolioDetails['platforms'] = {}; + const { SYMBOL: [filterBySymbol] = [] } = groupBy(filters, ({ type }) => { + return type; + }); + let currentAccounts: (AccountWithBalance & { Order?: Order[]; platform?: Platform; @@ -2197,7 +2181,7 @@ export class PortfolioService { } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { currentAccounts = await this.accountService.accounts({ include: { platform: true, tags: true }, - where: { id: filters[0].id } + where: { userId, id: filters[0].id } }); } else { const accountIds = Array.from( @@ -2225,75 +2209,103 @@ export class PortfolioService { // Iterate over the accounts plus a null entry to group activities without // an account into the unknown bucket for (const account of [...currentAccounts, null]) { + const currentAccountId = account?.id || UNKNOWN_KEY; + const currentPlatformId = account?.platformId || UNKNOWN_KEY; + const ordersByAccount = activities.filter(({ accountId }) => { return account ? accountId === account.id : !accountId; }); if (account) { - accounts[account.id] = { + // The cash balance is not part of a holding and would distort the value + // and thus the allocation per account and platform + const balanceInBaseCurrency = filterBySymbol + ? 0 + : this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + + accounts[currentAccountId] = { balance: account.balance, currency: account.currency, name: account.name, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ) + valueInBaseCurrency: balanceInBaseCurrency }; - if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { - platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += - this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ); + if (platforms[currentPlatformId]) { + platforms[currentPlatformId].valueInBaseCurrency = new Big( + platforms[currentPlatformId].valueInBaseCurrency + ) + .plus(balanceInBaseCurrency) + .toNumber(); } else { - platforms[account.platformId || UNKNOWN_KEY] = { + platforms[currentPlatformId] = { balance: account.balance, currency: account.currency, name: account.platform?.name, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ) + valueInBaseCurrency: balanceInBaseCurrency }; } } - for (const { account, assetProfile, quantity, type } of ordersByAccount) { - const currentValueOfSymbolInBaseCurrency = - getFactor(type) * - quantity * - (portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? - 0); + if (ordersByAccount.length === 0) { + continue; + } - if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { - accounts[account?.id || UNKNOWN_KEY].valueInBaseCurrency += - currentValueOfSymbolInBaseCurrency; - } else { - accounts[account?.id || UNKNOWN_KEY] = { - balance: 0, - currency: account?.currency, - name: account?.name, - valueInBaseCurrency: currentValueOfSymbolInBaseCurrency - }; - } + let quantityOfAccount = new Big(0); + let valueOfAccountInBaseCurrency = new Big(0); - if ( - platforms[account?.platformId || UNKNOWN_KEY]?.valueInBaseCurrency - ) { - platforms[account?.platformId || UNKNOWN_KEY].valueInBaseCurrency += - currentValueOfSymbolInBaseCurrency; - } else { - platforms[account?.platformId || UNKNOWN_KEY] = { - balance: 0, - currency: account?.currency, - name: account?.platform?.name, - valueInBaseCurrency: currentValueOfSymbolInBaseCurrency - }; - } + for (const { assetProfile, quantity, type } of ordersByAccount) { + const currentQuantityOfSymbol = new Big(quantity).mul(getFactor(type)); + + quantityOfAccount = quantityOfAccount.plus(currentQuantityOfSymbol); + + valueOfAccountInBaseCurrency = valueOfAccountInBaseCurrency.plus( + currentQuantityOfSymbol.mul( + portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? + 0 + ) + ); + } + + // The quantity is only meaningful if the activities are filtered by a + // single holding + const quantityOfHolding = filterBySymbol + ? quantityOfAccount.toNumber() + : undefined; + + if (accounts[currentAccountId]) { + accounts[currentAccountId].quantity = quantityOfHolding; + accounts[currentAccountId].valueInBaseCurrency = new Big( + accounts[currentAccountId].valueInBaseCurrency + ) + .plus(valueOfAccountInBaseCurrency) + .toNumber(); + } else { + accounts[currentAccountId] = { + balance: 0, + currency: account?.currency, + name: account?.name, + quantity: quantityOfHolding, + valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber() + }; + } + + if (platforms[currentPlatformId]) { + platforms[currentPlatformId].valueInBaseCurrency = new Big( + platforms[currentPlatformId].valueInBaseCurrency + ) + .plus(valueOfAccountInBaseCurrency) + .toNumber(); + } else { + platforms[currentPlatformId] = { + balance: 0, + currency: account?.currency, + name: account?.platform?.name, + valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber() + }; } } diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index a70fe87916..0cfc4d90e3 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -1,3 +1,4 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; @@ -31,6 +32,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { SubscriptionService } from './subscription.service'; +@AllowDuringImpersonation() @Controller('subscription') export class SubscriptionController { private readonly logger = new Logger(SubscriptionController.name); diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 2b679f34cc..50471c8bca 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -1,15 +1,16 @@ +import { AllowDuringImpersonation } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { Impersonation } from '@ghostfolio/api/decorators/impersonation.decorator'; import { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard'; import { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; -import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { - HEADER_KEY_IMPERSONATION, THROTTLE_SIGNUP_LIMIT, THROTTLE_SIGNUP_TTL } from '@ghostfolio/common/config'; @@ -18,6 +19,7 @@ import { UpdateOwnAccessTokenDto, UpdateUserSettingDto } from '@ghostfolio/common/dtos'; +import { isUserSettingOfAuthenticatedUser } from '@ghostfolio/common/helper'; import { AccessTokenResponse, User, @@ -25,7 +27,10 @@ import { UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; import { Body, @@ -51,11 +56,11 @@ import { merge, size } from 'lodash'; import { UserService } from './user.service'; +@AllowDuringImpersonation() @Controller('user') export class UserController { public constructor( private readonly configurationService: ConfigurationService, - private readonly impersonationService: ImpersonationService, private readonly jwtService: JwtService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, @@ -119,18 +124,15 @@ export class UserController { } @Get() - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getUser( @Headers('accept-language') acceptLanguage: string, - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + @Impersonation() { isActive, userId }: ImpersonationContext ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - return this.userService.getUser({ - impersonationUserId, + impersonationUserId: isActive ? userId : undefined, locale: acceptLanguage?.split(',')?.[0], user: this.request.user }); @@ -167,9 +169,27 @@ export class UserController { } @Put('setting') - @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard) @UseInterceptors(TransformDataSourceInResponseInterceptor) - public async updateUserSetting(@Body() data: UpdateUserSettingDto) { + public async updateUserSetting( + @Body() data: UpdateUserSettingDto, + @Impersonation() { isActive }: ImpersonationContext + ) { + if ( + isActive && + Object.keys(data).some((key) => { + return !isUserSettingOfAuthenticatedUser(key); + }) + ) { + // While impersonating, only the settings which stay with the + // authenticated user can be changed, as the update is always written + // back to the authenticated user + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + if ( size(data) === 1 && (data.benchmark || data.dateRange) && diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index a055f029e6..d74b90ede6 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -41,6 +41,7 @@ import { THROTTLE_DAILY_TTL } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; +import { resolveUserSettings } from '@ghostfolio/common/helper'; import { User as IUser, ReferralPartner, @@ -58,7 +59,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance import { Injectable, Logger } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; -import { Prisma, Role, Settings, User } from '@prisma/client'; +import { Prisma, Role, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; import { isNil, without } from 'lodash'; import { createHmac } from 'node:crypto'; @@ -127,7 +128,7 @@ export class UserService { accounts, activitiesCount, firstActivity, - impersonationUserSettings, + impersonationUser, tagsForUser ] = await Promise.all([ this.prismaService.access.findMany({ @@ -156,16 +157,16 @@ export class UserService { where: { userId: impersonationUserId || user.id } }), impersonationUserId - ? this.prismaService.settings.findUnique({ - where: { userId: impersonationUserId } - }) - : Promise.resolve(null), + ? this.user({ id: impersonationUserId }) + : Promise.resolve(null), this.tagService.getTagsForUser(impersonationUserId || user.id) ]); - const baseCurrency = - (impersonationUserSettings?.settings as UserSettings)?.baseCurrency ?? - (settings.settings as UserSettings)?.baseCurrency; + const resolvedUserSettings = resolveUserSettings({ + impersonationUserSettings: impersonationUser?.settings + ?.settings as UserSettings, + userSettings: settings.settings as UserSettings + }); let referralPartners: ReferralPartner[]; @@ -220,9 +221,9 @@ export class UserService { }), dateOfFirstActivity: firstActivity?.date ?? new Date(), settings: { - ...(settings.settings as UserSettings), - baseCurrency, - locale: (settings.settings as UserSettings)?.locale ?? locale + ...resolvedUserSettings, + baseCurrency: resolvedUserSettings.baseCurrency ?? DEFAULT_CURRENCY, + locale: resolvedUserSettings.locale ?? locale } }; } diff --git a/apps/api/src/decorators/allow-during-impersonation.decorator.ts b/apps/api/src/decorators/allow-during-impersonation.decorator.ts new file mode 100644 index 0000000000..4795bcf906 --- /dev/null +++ b/apps/api/src/decorators/allow-during-impersonation.decorator.ts @@ -0,0 +1,12 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ALLOW_DURING_IMPERSONATION_KEY = 'allow_during_impersonation'; + +/** + * Marks a controller or a route which modifies data of the authenticated user + * instead of data of the impersonated user, hence it stays available while an + * impersonation is active + */ +export function AllowDuringImpersonation() { + return SetMetadata(ALLOW_DURING_IMPERSONATION_KEY, true); +} diff --git a/apps/api/src/decorators/impersonation.decorator.ts b/apps/api/src/decorators/impersonation.decorator.ts new file mode 100644 index 0000000000..190542459b --- /dev/null +++ b/apps/api/src/decorators/impersonation.decorator.ts @@ -0,0 +1,26 @@ +import type { + ImpersonationContext, + RequestWithUser +} from '@ghostfolio/common/types'; + +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +/** + * Provides the impersonation context of the request, which requires the + * ImpersonationGuard to be applied to the route + */ +export const Impersonation = createParamDecorator( + (_data: unknown, context: ExecutionContext): ImpersonationContext => { + const { impersonation, user } = context + .switchToHttp() + .getRequest(); + + return ( + impersonation ?? { + isActive: false, + userId: user?.id, + userSettings: user?.settings?.settings ?? {} + } + ); + } +); diff --git a/apps/api/src/guards/impersonation-write.guard.ts b/apps/api/src/guards/impersonation-write.guard.ts new file mode 100644 index 0000000000..ec94c8e109 --- /dev/null +++ b/apps/api/src/guards/impersonation-write.guard.ts @@ -0,0 +1,53 @@ +import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; + +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +/** + * Blocks write requests while an impersonation is active, so that data of the + * authenticated user cannot be changed from a view presenting data of the + * impersonated user. The header is evaluated instead of the resolved context to + * fail closed, also for an identifier which cannot be resolved. + */ +@Injectable() +export class ImpersonationWriteGuard implements CanActivate { + public constructor(private readonly reflector: Reflector) {} + + public canActivate(context: ExecutionContext): boolean { + if (context.getType() !== 'http') { + return true; + } + + const request = context.switchToHttp().getRequest(); + + if (request.method === 'GET') { + return true; + } + + if (!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]) { + return true; + } + + const isAllowedDuringImpersonation = + this.reflector.getAllAndOverride( + ALLOW_DURING_IMPERSONATION_KEY, + [context.getHandler(), context.getClass()] + ); + + if (isAllowedDuringImpersonation) { + return true; + } + + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } +} diff --git a/apps/api/src/guards/impersonation.guard.ts b/apps/api/src/guards/impersonation.guard.ts new file mode 100644 index 0000000000..88500f1b39 --- /dev/null +++ b/apps/api/src/guards/impersonation.guard.ts @@ -0,0 +1,25 @@ +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; + +@Injectable() +export class ImpersonationGuard implements CanActivate { + public constructor( + private readonly impersonationService: ImpersonationService + ) {} + + public async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + + request.impersonation = await this.impersonationService.resolve({ + impersonationId: request.headers?.[ + HEADER_KEY_IMPERSONATION.toLowerCase() + ] as string, + user: request.user + }); + + return true; + } +} diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index 71c543a432..f3083e96c7 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -1,42 +1,92 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { UserSettings } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import type { RequestWithUser } from '@ghostfolio/common/types'; +import type { + ImpersonationContext, + UserWithSettings +} from '@ghostfolio/common/types'; -import { Inject, Injectable } from '@nestjs/common'; -import { REQUEST } from '@nestjs/core'; +import { Injectable } from '@nestjs/common'; @Injectable() export class ImpersonationService { - public constructor( - private readonly prismaService: PrismaService, - @Inject(REQUEST) private readonly request: RequestWithUser - ) {} + public constructor(private readonly prismaService: PrismaService) {} - public async validateImpersonationId(aId = '') { - if (this.request.user) { + public async resolve({ + impersonationId, + user + }: { + impersonationId?: string; + user?: UserWithSettings; + }): Promise { + const impersonatedUserId = await this.validateImpersonationId({ + impersonationId, + user + }); + + if (!impersonatedUserId) { + return { + isActive: false, + userId: user?.id, + userSettings: user?.settings?.settings ?? {} + }; + } + + const settings = await this.prismaService.settings.findUnique({ + where: { userId: impersonatedUserId } + }); + + return { + accessId: impersonationId, + isActive: true, + userId: impersonatedUserId, + userSettings: { + ...((settings?.settings ?? {}) as UserSettings), + baseCurrency: + (settings?.settings as UserSettings)?.baseCurrency ?? DEFAULT_CURRENCY + } + }; + } + + public async validateImpersonationId({ + impersonationId, + user + }: { + impersonationId?: string; + user?: UserWithSettings; + }) { + if (!impersonationId) { + return null; + } + + if (user) { const accessObject = await this.prismaService.access.findFirst({ where: { - granteeUserId: this.request.user.id, - id: aId + granteeUserId: user.id, + id: impersonationId } }); if (accessObject?.userId) { return accessObject.userId; } else if ( - hasPermission( - this.request.user.permissions, - permissions.impersonateAllUsers - ) + hasPermission(user.permissions, permissions.impersonateAllUsers) ) { - return aId; + // The identifier is a user id in this case, hence verify its existence + const impersonatedUser = await this.prismaService.user.findUnique({ + select: { id: true }, + where: { id: impersonationId } + }); + + return impersonatedUser?.id ?? null; } } else { // Public access const accessObject = await this.prismaService.access.findFirst({ where: { granteeUserId: null, - user: { id: aId } + user: { id: impersonationId } } }); diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index 8fc012488e..314fd21b52 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -848,13 +848,12 @@ export class GfAssetProfileDialogComponent implements OnInit { takeUntilDestroyed(this.destroyRef) ) .subscribe(({ price }) => { + const currency = this.assetProfileForm.controls.currency.value; + this.notificationService.alert({ - title: - $localize`The current market price is` + - ' ' + - price + - ' ' + - this.assetProfileForm.controls.currency.value + title: `${$localize`The current market price is`} ${price}${ + currency ? ` ${currency}` : '' + }` }); }); } diff --git a/apps/client/src/app/components/header/header.component.html b/apps/client/src/app/components/header/header.component.html index 35f072d727..eaccbb3326 100644 --- a/apps/client/src/app/components/header/header.component.html +++ b/apps/client/src/app/components/header/header.component.html @@ -217,7 +217,7 @@ Me - @for (accessItem of user()?.access; track accessItem) { + @for (accessItem of user()?.access; track accessItem.id) {