diff --git a/CHANGELOG.md b/CHANGELOG.md index 140e8aad8..e25d7527c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 @@ -19,8 +26,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 selector of the performance chart on the analysis page 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 diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 3bad0e171..54fadec68 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 f43aeedd5..ffb8ec6ab 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 63357e6b4..aa44857ad 100644 --- a/apps/api/src/app/activities/activities.controller.ts +++ b/apps/api/src/app/activities/activities.controller.ts @@ -1,35 +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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.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, - DEFAULT_CURRENCY, - 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, - UserSettings + 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, @@ -56,8 +53,6 @@ export class ActivitiesController { private readonly apiService: ApiService, private readonly dataProviderService: DataProviderService, private readonly dataGatheringService: DataGatheringService, - private readonly impersonationService: ImpersonationService, - private readonly prismaService: PrismaService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -66,7 +61,6 @@ export class ActivitiesController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseInterceptors(TransformDataSourceInRequestInterceptor) public async deleteActivities( - @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Query() { accounts, @@ -78,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; @@ -133,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, @@ -171,12 +158,6 @@ export class ActivitiesController { filterByTags: tags }); - const impersonationUserId = - await this.impersonationService.validateImpersonationId(impersonationId); - const userId = impersonationUserId || this.request.user.id; - - const userCurrency = await this.getUserCurrency(impersonationUserId); - const { activities, count } = await this.activitiesService.getActivities({ endDate, filters, @@ -185,10 +166,10 @@ export class ActivitiesController { sortDirection, startDate, take, - userCurrency, userId, includeDrafts: true, types: activityTypes, + userCurrency: userSettings.baseCurrency, withExcludedAccountsAndActivities: true }); @@ -196,23 +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 userId = impersonationUserId || this.request.user.id; - - const userCurrency = await this.getUserCurrency(impersonationUserId); - const { activities } = await this.activitiesService.getActivities({ - userCurrency, userId, includeDrafts: true, + userCurrency: userSettings.baseCurrency, withExcludedAccountsAndActivities: true }); @@ -384,18 +359,4 @@ export class ActivitiesController { } }); } - - private async getUserCurrency(impersonationUserId: string) { - if (!impersonationUserId) { - return this.request.user.settings.settings.baseCurrency; - } - - const settings = await this.prismaService.settings.findUnique({ - where: { userId: impersonationUserId } - }); - - return ( - (settings?.settings as UserSettings)?.baseCurrency ?? DEFAULT_CURRENCY - ); - } } diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts index 9009ded54..8653191b9 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 060abd247..71eedd16e 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 ddda044a7..e79abca50 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 c46589d74..45f7925ad 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 e3886e39c..ccf79ba99 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 4d34a2eff..680e7e682 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 6c8102db1..1a3fd3ba2 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 d0ef17844..70741374d 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 cbc68df93..cde2ade8c 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 5ffb756a0..356544acb 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 53df9bd92..79949a715 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 1fe42ab0d..4b6a3f1c9 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 03d50c284..6f7583acf 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 53daf3469..6093e5f87 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: undefined, 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 cd043b593..a61e1188c 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 78693239a..947612cb1 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 ebf03e3a9..6c0af4515 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 3eb9ca4d9..2edffc8d3 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 @@ -374,8 +365,8 @@ export class PortfolioController { if ( hasReadRestrictedAccessPermission({ - impersonationId, - accesses: this.request.user?.accessesGet + accesses: this.request.user?.accessesGet, + impersonationId: accessId }) || isRestrictedView(this.request.user) ) { @@ -406,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) { @@ -430,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, @@ -460,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, @@ -496,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) ) { @@ -544,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, @@ -572,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' @@ -658,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; @@ -687,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 b503b18e9..9b6bacd44 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 }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 25e79ceff..2cbafc076 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, @@ -126,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, @@ -198,8 +196,7 @@ export class PortfolioService { this.getDetails({ userId, withExcludedAccounts, - filters: filtersWithoutSearchQueryFilter, - impersonationId: undefined + filters: filtersWithoutSearchQueryFilter }), this.userService.user({ id: userId }) ]); @@ -389,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 }) => { @@ -412,7 +405,6 @@ export class PortfolioService { const { holdings: holdingsMap } = await this.getDetails({ dateRange, - impersonationId, userId, filters: filtersWithoutSearchQueryFilter }); @@ -437,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; @@ -517,7 +506,7 @@ export class PortfolioService { public async getDetails({ dateRange = DEFAULT_DATE_RANGE, filters, - impersonationId, + user: userFromCaller, userId, withExcludedAccounts = false, withMarkets = false, @@ -525,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( @@ -766,7 +755,6 @@ export class PortfolioService { if (withSummary) { summary = await this.getSummary({ filteredValueInBaseCurrency, - impersonationId, portfolioCalculator, userCurrency, userId, @@ -792,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); @@ -1041,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); @@ -1143,14 +1125,10 @@ export class PortfolioService { } public async getReport({ - impersonationId, userId }: { - impersonationId: string; userId: string; }): Promise { - userId = await this.getUserId(impersonationId, userId); - const user = await this.userService.user({ id: userId }); // The rules are evaluated against the portfolio of the (potentially @@ -1163,7 +1141,7 @@ export class PortfolioService { const { accounts, holdings, markets, marketsAdvanced, summary } = await this.getDetails({ - impersonationId, + user, userId, withMarkets: true, withSummary: true @@ -1925,7 +1903,6 @@ export class PortfolioService { balanceInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency, filteredValueInBaseCurrency, - impersonationId, portfolioCalculator, userCurrency, userId @@ -1933,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({ @@ -1971,7 +1946,6 @@ export class PortfolioService { } = await portfolioCalculator.getSnapshot(); const { performance } = await this.getPerformance({ - impersonationId, userId }); @@ -2168,13 +2142,6 @@ export class PortfolioService { return aUser?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY; } - private async getUserId(aImpersonationId: string, aUserId: string) { - const impersonationUserId = - await this.impersonationService.validateImpersonationId(aImpersonationId); - - return impersonationUserId || aUserId; - } - private getUserPerformanceCalculationType( aUser: UserWithSettings ): PerformanceCalculationType { diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index a70fe8791..0cfc4d90e 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 2b679f34c..50471c8bc 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 ada59f460..d74b90ede 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -163,9 +163,8 @@ export class UserService { ]); const resolvedUserSettings = resolveUserSettings({ - impersonationUserSettings: impersonationUserId - ? ((impersonationUser?.settings?.settings ?? {}) as UserSettings) - : undefined, + impersonationUserSettings: impersonationUser?.settings + ?.settings as UserSettings, userSettings: settings.settings as UserSettings }); 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 000000000..4795bcf90 --- /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 000000000..190542459 --- /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 000000000..ec94c8e10 --- /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 000000000..88500f1b3 --- /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 798a20e5c..f3083e96c 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -1,52 +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?: string) { - if (!aId) { + 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 (this.request.user) { + 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) ) { // The identifier is a user id in this case, hence verify its existence - const user = await this.prismaService.user.findUnique({ + const impersonatedUser = await this.prismaService.user.findUnique({ select: { id: true }, - where: { id: aId } + where: { id: impersonationId } }); - return user?.id ?? null; + 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/benchmark-comparator/benchmark-comparator.component.html b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html index eceb31df3..328cccba1 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html @@ -18,10 +18,7 @@ Compare with... diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts index e21f54aaf..0091ae5d7 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts @@ -69,7 +69,6 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { public readonly benchmarkDataItems = input([]); public readonly benchmarks = input[]>(); public readonly colorScheme = input.required(); - public readonly hasPermissionToUpdateUserSettings = input(); public readonly isLoading = input(); public readonly locale = input(getLocale()); public readonly performanceDataItems = input.required(); diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html index 1e60ebc85..a27e2e919 100644 --- a/apps/client/src/app/components/user-account-settings/user-account-settings.html +++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html @@ -4,16 +4,29 @@
-
+
Base Currency
- + + @if (hasImpersonationId) { + Derived from the portfolio you are viewing + }
diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index 3d22f0c68..82751b882 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -137,7 +137,6 @@ [benchmarkDataItems]="benchmarkDataItems" [benchmarks]="benchmarks" [colorScheme]="user?.settings?.colorScheme" - [hasPermissionToUpdateUserSettings]="!impersonationId" [isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart" [locale]="user?.settings?.locale" [performanceDataItems]="performanceDataItemsInPercentage" diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 265e27690..84d678458 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -153,6 +153,9 @@ export const DEFAULT_REDACTED_PATHS = [ 'platforms[*].balance', 'platforms[*].valueInBaseCurrency', 'quantity', + 'settings.emergencyFund', + 'settings.projectedTotalAmount', + 'settings.savingsRate', 'totalBalanceInBaseCurrency', 'totalDividendInBaseCurrency', 'totalInterestInBaseCurrency', diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index db3f9677d..d7e44d139 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -441,6 +441,20 @@ describe('Helper', () => { }); }); + it('Benchmark stays with the authenticated user', () => { + // The benchmark is a comparison of the person looking at the screen and + // is gated by their subscription, so it must not follow the impersonated + // user + const { benchmark } = resolveUserSettings({ + impersonationUserSettings: { + benchmark: '82fd8dcc-4a0e-4dd0-b6cb-7b8a4b03e6b1' + }, + userSettings: { benchmark: '1e5a0e6a-1b8b-4d0e-9f0a-4c2b3d5e6f7a' } + }); + + expect(benchmark).toEqual('1e5a0e6a-1b8b-4d0e-9f0a-4c2b3d5e6f7a'); + }); + it('Filters stay with the authenticated user', () => { // The filters are always written back to the authenticated user, so // reading them from the impersonated user would overwrite them @@ -475,8 +489,8 @@ describe('Helper', () => { // the authenticated user into the impersonated portfolio expect( resolveUserSettings({ - userSettings: { annualInterestRate: 3 }, - impersonationUserSettings: { annualInterestRate: 5 } + impersonationUserSettings: { annualInterestRate: 5 }, + userSettings: { annualInterestRate: 3 } }).annualInterestRate ).toEqual(5); }); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 2e4125ea9..9aab8d930 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -66,7 +66,8 @@ export const DATE_FORMAT_YEARLY = 'yyyy'; // The filters are included because they are always written back to the // authenticated user, so reading them from the impersonated user would // overwrite the filters of the authenticated user. -const PRESENTATION_USER_SETTINGS_KEYS: (keyof UserSettings)[] = [ +const USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER: (keyof UserSettings)[] = [ + 'benchmark', 'colorScheme', 'dateRange', 'filters.accounts', @@ -607,6 +608,12 @@ export function isSystemTag(tag?: { id: string }) { }); } +export function isUserSettingOfAuthenticatedUser(aKey: string) { + return USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER.includes( + aKey as keyof UserSettings + ); +} + export function isValidCustomAssetProfileSymbol(aSymbol: string) { return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol); } @@ -708,7 +715,7 @@ export function resolveUserSettings({ return { ...impersonationUserSettings, ...Object.fromEntries( - PRESENTATION_USER_SETTINGS_KEYS.map((key) => { + USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER.map((key) => { return [key, userSettings?.[key]]; }) ) diff --git a/libs/common/src/lib/types/impersonation-context.type.ts b/libs/common/src/lib/types/impersonation-context.type.ts new file mode 100644 index 000000000..f5afb6bc7 --- /dev/null +++ b/libs/common/src/lib/types/impersonation-context.type.ts @@ -0,0 +1,13 @@ +import { UserSettings } from '@ghostfolio/common/interfaces'; + +/** + * Describes whose data a request presents. The user id and the settings belong + * to the impersonated user while an impersonation is active and to the + * authenticated user otherwise, so a handler can use them unconditionally. + */ +export interface ImpersonationContext { + accessId?: string; + isActive: boolean; + userId: string; + userSettings: UserSettings; +} diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index 22d772610..c31d9079f 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -12,6 +12,7 @@ import type { Granularity } from './granularity.type'; import type { GroupBy } from './group-by.type'; import type { HoldingType } from './holding-type.type'; import type { HoldingsViewMode } from './holdings-view-mode.type'; +import type { ImpersonationContext } from './impersonation-context.type'; import type { MarketAdvanced } from './market-advanced.type'; import type { MarketDataPreset } from './market-data-preset.type'; import type { MarketState } from './market-state.type'; @@ -42,6 +43,7 @@ export type { GroupBy, HoldingType, HoldingsViewMode, + ImpersonationContext, Market, MarketAdvanced, MarketDataPreset, diff --git a/libs/common/src/lib/types/request-with-user.type.ts b/libs/common/src/lib/types/request-with-user.type.ts index a6bea37b5..9e37a5686 100644 --- a/libs/common/src/lib/types/request-with-user.type.ts +++ b/libs/common/src/lib/types/request-with-user.type.ts @@ -1,3 +1,9 @@ -import { UserWithSettings } from '@ghostfolio/common/types'; +import { + ImpersonationContext, + UserWithSettings +} from '@ghostfolio/common/types'; -export type RequestWithUser = Request & { user: UserWithSettings }; +export type RequestWithUser = Request & { + impersonation?: ImpersonationContext; + user: UserWithSettings; +};