Browse Source

Merge branch 'main' into task/remove-is-draft-of-activity

pull/7577/head
Thomas Kaul 3 weeks ago
committed by GitHub
parent
commit
252b5df4d8
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 43
      CHANGELOG.md
  2. 2
      apps/api/src/app/access/access.controller.ts
  3. 45
      apps/api/src/app/account/account.controller.ts
  4. 48
      apps/api/src/app/activities/activities.controller.ts
  5. 2
      apps/api/src/app/admin/admin.controller.ts
  6. 2
      apps/api/src/app/admin/queue/queue.controller.ts
  7. 7
      apps/api/src/app/app.module.ts
  8. 2
      apps/api/src/app/auth-device/auth-device.controller.ts
  9. 2
      apps/api/src/app/auth/auth.controller.ts
  10. 2
      apps/api/src/app/cache/cache.controller.ts
  11. 1
      apps/api/src/app/endpoints/ai/ai.controller.ts
  12. 3
      apps/api/src/app/endpoints/ai/ai.service.ts
  13. 2
      apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
  14. 2
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  15. 23
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  16. 17
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  17. 2
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  18. 2
      apps/api/src/app/endpoints/public/public.controller.ts
  19. 2
      apps/api/src/app/endpoints/tags/tags.controller.ts
  20. 22
      apps/api/src/app/endpoints/watchlist/watchlist.controller.ts
  21. 2
      apps/api/src/app/platform/platform.controller.ts
  22. 114
      apps/api/src/app/portfolio/portfolio.controller.ts
  23. 175
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  24. 186
      apps/api/src/app/portfolio/portfolio.service.ts
  25. 2
      apps/api/src/app/subscription/subscription.controller.ts
  26. 44
      apps/api/src/app/user/user.controller.ts
  27. 25
      apps/api/src/app/user/user.service.ts
  28. 12
      apps/api/src/decorators/allow-during-impersonation.decorator.ts
  29. 26
      apps/api/src/decorators/impersonation.decorator.ts
  30. 53
      apps/api/src/guards/impersonation-write.guard.ts
  31. 25
      apps/api/src/guards/impersonation.guard.ts
  32. 84
      apps/api/src/services/impersonation/impersonation.service.ts
  33. 11
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  34. 2
      apps/client/src/app/components/header/header.component.html
  35. 1
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  36. 62
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  37. 27
      apps/client/src/app/components/home-holdings/home-holdings.html
  38. 6
      apps/client/src/app/components/home-holdings/home-holdings.scss
  39. 3
      apps/client/src/app/components/markets/markets.component.ts
  40. 58
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  41. 35
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  42. 7
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  43. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  44. 326
      apps/client/src/locales/messages.ca.xlf
  45. 326
      apps/client/src/locales/messages.de.xlf
  46. 326
      apps/client/src/locales/messages.es.xlf
  47. 326
      apps/client/src/locales/messages.fr.xlf
  48. 326
      apps/client/src/locales/messages.it.xlf
  49. 326
      apps/client/src/locales/messages.ja.xlf
  50. 326
      apps/client/src/locales/messages.ko.xlf
  51. 326
      apps/client/src/locales/messages.nl.xlf
  52. 326
      apps/client/src/locales/messages.pl.xlf
  53. 326
      apps/client/src/locales/messages.pt.xlf
  54. 326
      apps/client/src/locales/messages.tr.xlf
  55. 326
      apps/client/src/locales/messages.uk.xlf
  56. 325
      apps/client/src/locales/messages.xlf
  57. 326
      apps/client/src/locales/messages.zh.xlf
  58. 4
      libs/common/src/lib/config.ts
  59. 132
      libs/common/src/lib/helper.spec.ts
  60. 53
      libs/common/src/lib/helper.ts
  61. 2
      libs/common/src/lib/interfaces/index.ts
  62. 2
      libs/common/src/lib/interfaces/portfolio-details.interface.ts
  63. 4
      libs/common/src/lib/interfaces/toggle-option.interface.ts
  64. 2
      libs/common/src/lib/types/account-with-value.type.ts
  65. 13
      libs/common/src/lib/types/impersonation-context.type.ts
  66. 4
      libs/common/src/lib/types/index.ts
  67. 10
      libs/common/src/lib/types/request-with-user.type.ts
  68. 9
      libs/common/src/lib/types/toggle-option.type.ts
  69. 20
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  70. 7
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  71. 4
      libs/ui/src/lib/carousel/carousel.component.html
  72. 1
      libs/ui/src/lib/currency-selector/currency-selector.component.html
  73. 26
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  74. 18
      libs/ui/src/lib/shared/abstract-mat-form-field.ts
  75. 4
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  76. 14
      libs/ui/src/lib/toggle/toggle.component.html
  77. 16
      libs/ui/src/lib/toggle/toggle.component.scss
  78. 43
      libs/ui/src/lib/toggle/toggle.component.stories.ts
  79. 6
      libs/ui/src/lib/toggle/toggle.component.ts
  80. 4
      package-lock.json
  81. 2
      package.json

43
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 - 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 ## 3.46.0 - 2026-08-09
### Added ### Added

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; 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'; import { AccessService } from './access.service';
@AllowDuringImpersonation()
@Controller('access') @Controller('access')
export class AccessController { export class AccessController {
public constructor( public constructor(

45
apps/api/src/app/account/account.controller.ts

@ -1,13 +1,12 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.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 { 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 { 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 { 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 { 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 { 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 { import {
CreateAccountDto, CreateAccountDto,
TransferBalanceDto, TransferBalanceDto,
@ -19,14 +18,16 @@ import {
AccountsResponse AccountsResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -49,10 +50,8 @@ export class AccountController {
private readonly accountBalanceService: AccountBalanceService, private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -85,18 +84,15 @@ export class AccountController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getAllAccounts( public async getAllAccounts(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Query('dataSource') filterByDataSource?: string, @Query('dataSource') filterByDataSource?: string,
@Query('query') filterBySearchQuery?: string, @Query('query') filterBySearchQuery?: string,
@Query('symbol') filterBySymbol?: string @Query('symbol') filterBySymbol?: string
): Promise<AccountsResponse> { ): Promise<AccountsResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByDataSource, filterByDataSource,
filterBySearchQuery, filterBySearchQuery,
@ -105,25 +101,22 @@ export class AccountController {
return this.portfolioService.getAccountsWithAggregations({ return this.portfolioService.getAccountsWithAggregations({
filters, filters,
userId: impersonationUserId || this.request.user.id, userId,
withExcludedAccounts: true withExcludedAccounts: true
}); });
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountById( public async getAccountById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Param('id') id: string @Param('id') id: string
): Promise<AccountResponse> { ): Promise<AccountResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const accountsWithAggregations = const accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations({ await this.portfolioService.getAccountsWithAggregations({
userId,
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userId: impersonationUserId || this.request.user.id,
withExcludedAccounts: true withExcludedAccounts: true
}); });
@ -131,22 +124,16 @@ export class AccountController {
} }
@Get(':id/balances') @Get(':id/balances')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById( public async getAccountBalancesById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('id') id: string @Param('id') id: string
): Promise<AccountBalancesResponse> { ): Promise<AccountBalancesResponse> {
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({ return this.accountBalanceService.getAccountBalances({
userId, userId,
filters: [{ id, type: 'ACCOUNT' }], filters: [{ id, type: 'ACCOUNT' }],
userCurrency: settings.settings.baseCurrency userCurrency: userSettings.baseCurrency
}); });
} }

48
apps/api/src/app/activities/activities.controller.ts

@ -1,32 +1,32 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.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 { 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 { isActivityInFuture } from '@ghostfolio/api/helper/activity.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; 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 { 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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { import {
ActivitiesResponse, ActivitiesResponse,
ActivityResponse ActivityResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -53,7 +53,6 @@ export class ActivitiesController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -62,7 +61,6 @@ export class ActivitiesController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async deleteActivities( public async deleteActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query() @Query()
{ {
accounts, accounts,
@ -74,13 +72,6 @@ export class ActivitiesController {
tags tags
}: ActivitiesFilterDto }: ActivitiesFilterDto
): Promise<number> { ): Promise<number> {
if (impersonationId) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let endDate: Date; let endDate: Date;
let startDate: Date; let startDate: Date;
@ -129,12 +120,12 @@ export class ActivitiesController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllActivities( public async getAllActivities(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -167,11 +158,6 @@ export class ActivitiesController {
filterByTags: tags filterByTags: tags
}); });
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
filters, filters,
@ -180,10 +166,10 @@ export class ActivitiesController {
sortDirection, sortDirection,
startDate, startDate,
take, take,
userCurrency, userId,
includeDrafts: true, includeDrafts: true,
types: activityTypes, types: activityTypes,
userId: impersonationUserId || this.request.user.id, userCurrency: userSettings.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -191,21 +177,17 @@ export class ActivitiesController {
} }
@Get(':id') @Get(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getActivityById( public async getActivityById(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('id') id: string @Param('id') id: string
): Promise<ActivityResponse> { ): Promise<ActivityResponse> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency;
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
userCurrency, userId,
includeDrafts: true, includeDrafts: true,
userId: impersonationUserId || this.request.user.id, userCurrency: userSettings.baseCurrency,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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'; 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 { AdminService } from './admin.service';
import { PropertyKeyPipe } from './pipes/property-key.pipe'; import { PropertyKeyPipe } from './pipes/property-key.pipe';
@AllowDuringImpersonation()
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
private readonly logger = new Logger(AdminController.name); private readonly logger = new Logger(AdminController.name);

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AdminJobs } from '@ghostfolio/common/interfaces'; import { AdminJobs } from '@ghostfolio/common/interfaces';
@ -16,6 +17,7 @@ import { JobStatus } from 'bull';
import { QueueService } from './queue.service'; import { QueueService } from './queue.service';
@AllowDuringImpersonation()
@Controller('admin/queue') @Controller('admin/queue')
export class QueueController { export class QueueController {
public constructor(private readonly queueService: QueueService) {} public constructor(private readonly queueService: QueueService) {}

7
apps/api/src/app/app.module.ts

@ -1,5 +1,6 @@
import { EventsModule } from '@ghostfolio/api/events/events.module'; import { EventsModule } from '@ghostfolio/api/events/events.module';
import { PortfolioSnapshotComputationExceptionFilter } from '@ghostfolio/api/filters/portfolio-snapshot-computation-exception.filter'; 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 { getRedisConnectionOptions } from '@ghostfolio/api/helper/redis.helper';
import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware';
import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.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 { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; 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 { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
@ -191,6 +192,10 @@ import { UserModule } from './user/user.module';
{ {
provide: APP_FILTER, provide: APP_FILTER,
useClass: PortfolioSnapshotComputationExceptionFilter useClass: PortfolioSnapshotComputationExceptionFilter
},
{
provide: APP_GUARD,
useClass: ImpersonationWriteGuard
} }
] ]
}) })

2
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 { 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -16,6 +17,7 @@ import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@AllowDuringImpersonation()
@Controller('auth-device') @Controller('auth-device')
export class AuthDeviceController { export class AuthDeviceController {
public constructor( public constructor(

2
apps/api/src/app/auth/auth.controller.ts

@ -1,4 +1,5 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; 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 { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; 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'; import { AuthService } from './auth.service';
@AllowDuringImpersonation()
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
public constructor( public constructor(

2
apps/api/src/app/cache/cache.controller.ts

@ -1,4 +1,5 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -6,6 +7,7 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Controller, Post, UseGuards } from '@nestjs/common'; import { Controller, Post, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@AllowDuringImpersonation()
@Controller('cache') @Controller('cache')
export class CacheController { export class CacheController {
public constructor(private readonly redisCacheService: RedisCacheService) {} public constructor(private readonly redisCacheService: RedisCacheService) {}

1
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -46,7 +46,6 @@ export class AiController {
const prompt = await this.aiService.getPrompt({ const prompt = await this.aiService.getPrompt({
filters, filters,
mode, mode,
impersonationId: undefined,
languageCode: this.request.user.settings.settings.language, languageCode: this.request.user.settings.settings.language,
userCurrency: this.request.user.settings.settings.baseCurrency, userCurrency: this.request.user.settings.settings.baseCurrency,
userId: this.request.user.id userId: this.request.user.id

3
apps/api/src/app/endpoints/ai/ai.service.ts

@ -70,14 +70,12 @@ export class AiService {
public async getPrompt({ public async getPrompt({
filters, filters,
impersonationId,
languageCode, languageCode,
mode, mode,
userCurrency, userCurrency,
userId userId
}: { }: {
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
languageCode: string; languageCode: string;
mode: AiPromptMode; mode: AiPromptMode;
userCurrency: string; userCurrency: string;
@ -85,7 +83,6 @@ export class AiService {
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters, filters,
impersonationId,
userId userId
}); });

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; 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 { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@AllowDuringImpersonation()
@Controller('api-keys') @Controller('api-keys')
export class ApiKeysController { export class ApiKeysController {
public constructor( public constructor(

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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'; 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'; import { AssetProfilesService } from './asset-profiles.service';
@AllowDuringImpersonation()
@Controller('asset-profiles') @Controller('asset-profiles')
export class AssetProfilesController { export class AssetProfilesController {
public constructor( public constructor(

23
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 { 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 { 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 { 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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { ImpersonationContext } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Query, Query,
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -36,13 +35,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarksService } from './benchmarks.service'; import { BenchmarksService } from './benchmarks.service';
import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto'; import { GetBenchmarkMarketDataDto } from './get-benchmark-market-data.dto';
@AllowDuringImpersonation()
@Controller('benchmarks') @Controller('benchmarks')
export class BenchmarksController { export class BenchmarksController {
public constructor( public constructor(
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly benchmarksService: BenchmarksService, private readonly benchmarksService: BenchmarksService
@Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@ -112,10 +111,10 @@ export class BenchmarksController {
} }
@Get(':dataSource/:symbol/:startDateString') @Get(':dataSource/:symbol/:startDateString')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataForUser(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId, userSettings }: ImpersonationContext,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@ -147,12 +146,12 @@ export class BenchmarksController {
dataSource, dataSource,
endDate, endDate,
filters, filters,
impersonationId,
startDate, startDate,
symbol, symbol,
userId,
userSettings,
withExcludedAccounts, withExcludedAccounts,
dateRange: range, dateRange: range
user: this.request.user
}); });
} }
} }

17
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -7,9 +7,10 @@ import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
Filter Filter,
UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format, isSameDay } from 'date-fns'; import { format, isSameDay } from 'date-fns';
@ -32,28 +33,26 @@ export class BenchmarksService {
dateRange, dateRange,
endDate = new Date(), endDate = new Date(),
filters, filters,
impersonationId,
startDate, startDate,
symbol, symbol,
user, userId,
userSettings,
withExcludedAccounts withExcludedAccounts
}: { }: {
dateRange: DateRange; dateRange: DateRange;
endDate?: Date; endDate?: Date;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
startDate: Date; startDate: Date;
user: UserWithSettings; userId: string;
userSettings: UserSettings;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> { } & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = userSettings.baseCurrency;
const userId = user.id;
const { chart } = await this.portfolioService.getPerformance({ const { chart } = await this.portfolioService.getPerformance({
dateRange, dateRange,
filters, filters,
impersonationId,
userId, userId,
withExcludedAccounts withExcludedAccounts
}); });

2
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 { 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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; 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 { parseISO } from 'date-fns';
import { getReasonPhrase, StatusCodes } from 'http-status-codes'; import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@AllowDuringImpersonation()
@Controller('market-data') @Controller('market-data')
export class MarketDataController { export class MarketDataController {
public constructor( public constructor(

2
apps/api/src/app/endpoints/public/public.controller.ts

@ -78,7 +78,6 @@ export class PublicController {
] = await Promise.all([ ] = await Promise.all([
this.portfolioService.getDetails({ this.portfolioService.getDetails({
filters, filters,
impersonationId: access.userId,
userId: user.id, userId: user.id,
withMarkets: true withMarkets: true
}), }),
@ -86,7 +85,6 @@ export class PublicController {
return this.portfolioService.getPerformance({ return this.portfolioService.getPerformance({
dateRange, dateRange,
filters, filters,
impersonationId: undefined,
userId: user.id userId: user.id
}); });
}) })

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
@ -23,6 +24,7 @@ import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@AllowDuringImpersonation()
@Controller('tags') @Controller('tags')
export class TagsController { export class TagsController {
public constructor( public constructor(

22
apps/api/src/app/endpoints/watchlist/watchlist.controller.ts

@ -1,20 +1,22 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.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 { 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 { 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 { 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 { CreateWatchlistItemDto } from '@ghostfolio/common/dtos';
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -32,7 +34,6 @@ import { WatchlistService } from './watchlist.service';
@Controller('watchlist') @Controller('watchlist')
export class WatchlistController { export class WatchlistController {
public constructor( public constructor(
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly watchlistService: WatchlistService private readonly watchlistService: WatchlistService
) {} ) {}
@ -81,17 +82,12 @@ export class WatchlistController {
@Get() @Get()
@HasPermission(permissions.readWatchlist) @HasPermission(permissions.readWatchlist)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getWatchlistItems( public async getWatchlistItems(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Impersonation() { userId }: ImpersonationContext
): Promise<WatchlistResponse> { ): Promise<WatchlistResponse> {
const impersonationUserId = const watchlist = await this.watchlistService.getWatchlistItems(userId);
await this.impersonationService.validateImpersonationId(impersonationId);
const watchlist = await this.watchlistService.getWatchlistItems(
impersonationUserId || this.request.user.id
);
return { return {
watchlist watchlist

2
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
@ -20,6 +21,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
@AllowDuringImpersonation()
@Controller('platform') @Controller('platform')
export class PlatformController { export class PlatformController {
public constructor(private readonly platformService: PlatformService) {} public constructor(private readonly platformService: PlatformService) {}

114
apps/api/src/app/portfolio/portfolio.controller.ts

@ -1,7 +1,8 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; 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 { 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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ImpersonationGuard } from '@ghostfolio/api/guards/impersonation.guard';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject 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 { 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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 { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { import { UNKNOWN_KEY } from '@ghostfolio/common/config';
HEADER_KEY_IMPERSONATION,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
PortfolioDetails, PortfolioDetails,
@ -33,13 +30,15 @@ import {
isRestrictedView, isRestrictedView,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -69,19 +68,17 @@ export class PortfolioController {
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser
private readonly userService: UserService
) {} ) {}
@Get('details') @Get('details')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails( public async getDetails(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { accessId, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts: filterByAccounts, accounts: filterByAccounts,
@ -120,10 +117,9 @@ export class PortfolioController {
summary summary
} = await this.portfolioService.getDetails({ } = await this.portfolioService.getDetails({
filters, filters,
impersonationId, userId,
withMarkets, withMarkets,
dateRange: range, dateRange: range,
userId: this.request.user.id,
withSummary: true withSummary: true
}); });
@ -135,8 +131,8 @@ export class PortfolioController {
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -179,8 +175,8 @@ export class PortfolioController {
if ( if (
hasDetails === false || hasDetails === false ||
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -323,10 +319,10 @@ export class PortfolioController {
} }
@Get('dividends') @Get('dividends')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getDividends( public async getDividends(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { accessId, userId, userSettings }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -346,12 +342,7 @@ export class PortfolioController {
filterByTags: tags filterByTags: tags
}); });
const impersonationUserId = const userCurrency = userSettings.baseCurrency;
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 { endDate, startDate } = getIntervalFromDateRange({ const { endDate, startDate } = getIntervalFromDateRange({
dateRange: range dateRange: range
@ -368,13 +359,14 @@ export class PortfolioController {
let dividends = this.portfolioService.getDividends({ let dividends = this.portfolioService.getDividends({
activities, activities,
groupBy groupBy,
userCurrency
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -405,17 +397,16 @@ export class PortfolioController {
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
public async getHolding( public async getHolding(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<PortfolioHoldingResponse> { ): Promise<PortfolioHoldingResponse> {
const holding = await this.portfolioService.getHolding({ const holding = await this.portfolioService.getHolding({
dataSource, dataSource,
impersonationId,
symbol, symbol,
userId: this.request.user.id userId
}); });
if (!holding) { if (!holding) {
@ -429,12 +420,12 @@ export class PortfolioController {
} }
@Get('holdings') @Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings( public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -459,19 +450,18 @@ export class PortfolioController {
const holdings = await this.portfolioService.getHoldings({ const holdings = await this.portfolioService.getHoldings({
filters, filters,
impersonationId, userId,
dateRange: range, dateRange: range
userId: this.request.user.id
}); });
return { holdings }; return { holdings };
} }
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getInvestments( public async getInvestments(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { accessId, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -495,15 +485,14 @@ export class PortfolioController {
await this.portfolioService.getInvestments({ await this.portfolioService.getInvestments({
filters, filters,
groupBy, groupBy,
impersonationId, userId,
dateRange: range, dateRange: range
userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
@ -543,13 +532,13 @@ export class PortfolioController {
} }
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(PerformanceLoggingInterceptor) @UseInterceptors(PerformanceLoggingInterceptor)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2') @Version('2')
public async getPerformanceV2( public async getPerformanceV2(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, @Impersonation() { accessId, userId }: ImpersonationContext,
@Query() @Query()
{ {
accounts, accounts,
@ -571,16 +560,15 @@ export class PortfolioController {
const performanceInformation = await this.portfolioService.getPerformance({ const performanceInformation = await this.portfolioService.getPerformance({
filters, filters,
impersonationId, userId,
withExcludedAccounts, withExcludedAccounts,
dateRange: range, dateRange: range
userId: this.request.user.id
}); });
if ( if (
hasReadRestrictedAccessPermission({ hasReadRestrictedAccessPermission({
impersonationId, accesses: this.request.user?.accessesGet,
accesses: this.request.user?.accessesGet impersonationId: accessId
}) || }) ||
isRestrictedView(this.request.user) || isRestrictedView(this.request.user) ||
this.request.user.settings.settings.viewMode === 'ZEN' this.request.user.settings.settings.viewMode === 'ZEN'
@ -657,18 +645,20 @@ export class PortfolioController {
} }
@Get('report') @Get('report')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
public async getReport( public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Impersonation() { accessId, userId }: ImpersonationContext
): Promise<PortfolioReportResponse> { ): Promise<PortfolioReportResponse> {
const report = await this.portfolioService.getReport({ const report = await this.portfolioService.getReport({ userId });
impersonationId,
userId: this.request.user.id
});
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && hasReadRestrictedAccessPermission({
this.request.user.subscription?.type === SubscriptionType.Basic 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) { for (const category of report.xRay.categories) {
category.rules = null; category.rules = null;
@ -686,7 +676,7 @@ export class PortfolioController {
@HasPermission(permissions.updateActivity) @HasPermission(permissions.updateActivity)
@Put('holding/:dataSource/:symbol/tags') @Put('holding/:dataSource/:symbol/tags')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
public async updateHoldingTags( public async updateHoldingTags(
@Body() data: UpdateHoldingTagsDto, @Body() data: UpdateHoldingTagsDto,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,

175
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
@ -30,7 +29,6 @@ describe('PortfolioService', () => {
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let impersonationService: ImpersonationService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory; let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioService: PortfolioService; let portfolioService: PortfolioService;
let symbolProfileService: SymbolProfileService; let symbolProfileService: SymbolProfileService;
@ -77,8 +75,6 @@ describe('PortfolioService', () => {
null null
); );
impersonationService = new ImpersonationService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory( portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService, configurationService,
null, null,
@ -110,7 +106,6 @@ describe('PortfolioService', () => {
dataProviderService, dataProviderService,
exchangeRateDataService, exchangeRateDataService,
null, null,
impersonationService,
null, null,
null, null,
symbolProfileService, symbolProfileService,
@ -245,10 +240,6 @@ describe('PortfolioService', () => {
.spyOn(dataProviderService, 'getDataSourceForExchangeRates') .spyOn(dataProviderService, 'getDataSourceForExchangeRates')
.mockReturnValue(DataSource.YAHOO); .mockReturnValue(DataSource.YAHOO);
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest jest
.spyOn(symbolProfileService, 'getSymbolProfiles') .spyOn(symbolProfileService, 'getSymbolProfiles')
.mockResolvedValue([]); .mockResolvedValue([]);
@ -331,7 +322,6 @@ describe('PortfolioService', () => {
const { holdings } = await portfolioService.getDetails({ const { holdings } = await portfolioService.getDetails({
filters: [], filters: [],
impersonationId: userDummyData.id,
userId: userDummyData.id userId: userDummyData.id
}); });
@ -371,10 +361,6 @@ describe('PortfolioService', () => {
.spyOn(activitiesService, 'getActivities') .spyOn(activitiesService, 'getActivities')
.mockResolvedValue({ activities: [], count: 0 }); .mockResolvedValue({ activities: [], count: 0 });
jest
.spyOn(impersonationService, 'validateImpersonationId')
.mockResolvedValue(null);
jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({ jest.spyOn(portfolioService, 'getPerformance').mockResolvedValue({
performance: { performance: {
currentValueInBaseCurrency: 3000, currentValueInBaseCurrency: 3000,
@ -408,7 +394,6 @@ describe('PortfolioService', () => {
balanceInBaseCurrency: 1000, balanceInBaseCurrency: 1000,
emergencyFundHoldingsValueInBaseCurrency: 0, emergencyFundHoldingsValueInBaseCurrency: 0,
filteredValueInBaseCurrency: new Big(3000), filteredValueInBaseCurrency: new Big(3000),
impersonationId: undefined,
userCurrency: 'CHF', userCurrency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });
@ -426,7 +411,10 @@ describe('PortfolioService', () => {
return ( return (
portfolioService as unknown as { portfolioService as unknown as {
getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{
accounts: Record<string, { valueInBaseCurrency: number }>; accounts: Record<
string,
{ quantity?: number; valueInBaseCurrency: number }
>;
platforms: Record<string, { valueInBaseCurrency: number }>; platforms: Record<string, { valueInBaseCurrency: number }>;
}>; }>;
} }
@ -443,6 +431,10 @@ describe('PortfolioService', () => {
}; };
beforeEach(() => { beforeEach(() => {
jest
.spyOn(accountService, 'accounts')
.mockResolvedValue([account] as unknown as AccountWithBalance[]);
jest jest
.spyOn(accountService, 'getAccounts') .spyOn(accountService, 'getAccounts')
.mockResolvedValue([account] as unknown as AccountWithBalance[]); .mockResolvedValue([account] as unknown as AccountWithBalance[]);
@ -510,5 +502,156 @@ describe('PortfolioService', () => {
expect(accounts[UNKNOWN_KEY]).toBeUndefined(); expect(accounts[UNKNOWN_KEY]).toBeUndefined();
expect(platforms[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);
});
}); });
}); });

186
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
getAnnualizedPerformancePercent, getAnnualizedPerformancePercent,
@ -45,7 +44,8 @@ import {
getSum, getSum,
isAccountExcluded, isAccountExcluded,
isDraftActivity, isDraftActivity,
parseDate parseDate,
resolveUserSettings
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AccountsResponse, AccountsResponse,
@ -125,7 +125,6 @@ export class PortfolioService {
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService, private readonly i18nService: I18nService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -195,10 +194,9 @@ export class PortfolioService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getDetails({ this.getDetails({
userId,
withExcludedAccounts, withExcludedAccounts,
filters: filtersWithoutSearchQueryFilter, filters: filtersWithoutSearchQueryFilter
impersonationId: userId,
userId: this.request.user.id
}), }),
this.userService.user({ id: userId }) this.userService.user({ id: userId })
]); ]);
@ -248,6 +246,10 @@ export class PortfolioService {
} }
} }
const quantityOfHolding = filterBySymbol
? (details.accounts[account.id]?.quantity ?? 0)
: undefined;
const valueInBaseCurrency = const valueInBaseCurrency =
details.accounts[account.id]?.valueInBaseCurrency ?? 0; details.accounts[account.id]?.valueInBaseCurrency ?? 0;
@ -263,6 +265,7 @@ export class PortfolioService {
account.currency, account.currency,
userCurrency userCurrency
), ),
quantity: quantityOfHolding,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency, valueInBaseCurrency,
userCurrency, userCurrency,
@ -355,10 +358,12 @@ export class PortfolioService {
public getDividends({ public getDividends({
activities, activities,
groupBy groupBy,
userCurrency
}: { }: {
activities: Activity[]; activities: Activity[];
groupBy?: GroupBy; groupBy?: GroupBy;
userCurrency: string;
}): InvestmentItem[] { }): InvestmentItem[] {
let dividends = activities.map(({ currency, date, value }) => { let dividends = activities.map(({ currency, date, value }) => {
return { return {
@ -366,7 +371,7 @@ export class PortfolioService {
investment: this.exchangeRateDataService.toCurrency( investment: this.exchangeRateDataService.toCurrency(
value, value,
currency, currency,
this.getUserCurrency() userCurrency
) )
}; };
}); });
@ -381,16 +386,12 @@ export class PortfolioService {
public async getHoldings({ public async getHoldings({
dateRange, dateRange,
filters, filters,
impersonationId,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
userId: string; userId: string;
}) { }) {
userId = await this.getUserId(impersonationId, userId);
const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy( const { SEARCH_QUERY: [filterBySearchQuery] = [] } = groupBy(
filters, filters,
({ type }) => { ({ type }) => {
@ -404,7 +405,6 @@ export class PortfolioService {
const { holdings: holdingsMap } = await this.getDetails({ const { holdings: holdingsMap } = await this.getDetails({
dateRange, dateRange,
impersonationId,
userId, userId,
filters: filtersWithoutSearchQueryFilter filters: filtersWithoutSearchQueryFilter
}); });
@ -429,16 +429,13 @@ export class PortfolioService {
dateRange, dateRange,
filters, filters,
groupBy, groupBy,
impersonationId,
userId userId
}: { }: {
dateRange: DateRange; dateRange: DateRange;
filters?: Filter[]; filters?: Filter[];
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string;
userId: string; userId: string;
}): Promise<PortfolioInvestmentsResponse> { }): Promise<PortfolioInvestmentsResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate; const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate;
@ -509,7 +506,7 @@ export class PortfolioService {
public async getDetails({ public async getDetails({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
impersonationId, user: userFromCaller,
userId, userId,
withExcludedAccounts = false, withExcludedAccounts = false,
withMarkets = false, withMarkets = false,
@ -517,14 +514,14 @@ export class PortfolioService {
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; user?: UserWithSettings;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
withMarkets?: boolean; withMarkets?: boolean;
withSummary?: boolean; withSummary?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
userId = await this.getUserId(impersonationId, userId); const user =
const user = await this.userService.user({ id: userId }); userFromCaller ?? (await this.userService.user({ id: userId }));
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const emergencyFund = new Big( const emergencyFund = new Big(
@ -758,7 +755,6 @@ export class PortfolioService {
if (withSummary) { if (withSummary) {
summary = await this.getSummary({ summary = await this.getSummary({
filteredValueInBaseCurrency, filteredValueInBaseCurrency,
impersonationId,
portfolioCalculator, portfolioCalculator,
userCurrency, userCurrency,
userId, userId,
@ -784,14 +780,11 @@ export class PortfolioService {
public async getHolding({ public async getHolding({
dataSource, dataSource,
impersonationId,
symbol, symbol,
userId userId
}: { }: {
impersonationId?: string;
userId: string; userId: string;
} & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> { } & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -1033,16 +1026,13 @@ export class PortfolioService {
public async getPerformance({ public async getPerformance({
dateRange = DEFAULT_DATE_RANGE, dateRange = DEFAULT_DATE_RANGE,
filters, filters,
impersonationId,
userId userId
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -1135,18 +1125,23 @@ export class PortfolioService {
} }
public async getReport({ public async getReport({
impersonationId,
userId userId
}: { }: {
impersonationId: string;
userId: string; userId: string;
}): Promise<PortfolioReportResponse> { }): Promise<PortfolioReportResponse> {
userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId });
const userSettings = this.request.user.settings.settings as UserSettings;
// 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 } = const { accounts, holdings, markets, marketsAdvanced, summary } =
await this.getDetails({ await this.getDetails({
impersonationId, user,
userId, userId,
withMarkets: true, withMarkets: true,
withSummary: true withSummary: true
@ -1908,7 +1903,6 @@ export class PortfolioService {
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundHoldingsValueInBaseCurrency, emergencyFundHoldingsValueInBaseCurrency,
filteredValueInBaseCurrency, filteredValueInBaseCurrency,
impersonationId,
portfolioCalculator, portfolioCalculator,
userCurrency, userCurrency,
userId userId
@ -1916,12 +1910,10 @@ export class PortfolioService {
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
emergencyFundHoldingsValueInBaseCurrency: number; emergencyFundHoldingsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big; filteredValueInBaseCurrency: Big;
impersonationId: string;
portfolioCalculator: PortfolioCalculator; portfolioCalculator: PortfolioCalculator;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<PortfolioSummary> { }): Promise<PortfolioSummary> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
@ -1954,7 +1946,6 @@ export class PortfolioService {
} = await portfolioCalculator.getSnapshot(); } = await portfolioCalculator.getSnapshot();
const { performance } = await this.getPerformance({ const { performance } = await this.getPerformance({
impersonationId,
userId userId
}); });
@ -2148,18 +2139,7 @@ export class PortfolioService {
} }
private getUserCurrency(aUser?: UserWithSettings) { private getUserCurrency(aUser?: UserWithSettings) {
return ( return aUser?.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
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;
} }
private getUserPerformanceCalculationType( private getUserPerformanceCalculationType(
@ -2186,6 +2166,10 @@ export class PortfolioService {
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {}; const platforms: PortfolioDetails['platforms'] = {};
const { SYMBOL: [filterBySymbol] = [] } = groupBy(filters, ({ type }) => {
return type;
});
let currentAccounts: (AccountWithBalance & { let currentAccounts: (AccountWithBalance & {
Order?: Order[]; Order?: Order[];
platform?: Platform; platform?: Platform;
@ -2197,7 +2181,7 @@ export class PortfolioService {
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({
include: { platform: true, tags: true }, include: { platform: true, tags: true },
where: { id: filters[0].id } where: { userId, id: filters[0].id }
}); });
} else { } else {
const accountIds = Array.from( const accountIds = Array.from(
@ -2225,77 +2209,105 @@ export class PortfolioService {
// Iterate over the accounts plus a null entry to group activities without // Iterate over the accounts plus a null entry to group activities without
// an account into the unknown bucket // an account into the unknown bucket
for (const account of [...currentAccounts, null]) { for (const account of [...currentAccounts, null]) {
const currentAccountId = account?.id || UNKNOWN_KEY;
const currentPlatformId = account?.platformId || UNKNOWN_KEY;
const ordersByAccount = activities.filter(({ accountId }) => { const ordersByAccount = activities.filter(({ accountId }) => {
return account ? accountId === account.id : !accountId; return account ? accountId === account.id : !accountId;
}); });
if (account) { if (account) {
accounts[account.id] = { // The cash balance is not part of a holding and would distort the value
balance: account.balance, // and thus the allocation per account and platform
currency: account.currency, const balanceInBaseCurrency = filterBySymbol
name: account.name, ? 0
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( : this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
userCurrency userCurrency
) );
accounts[currentAccountId] = {
balance: account.balance,
currency: account.currency,
name: account.name,
valueInBaseCurrency: balanceInBaseCurrency
}; };
if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { if (platforms[currentPlatformId]) {
platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += platforms[currentPlatformId].valueInBaseCurrency = new Big(
this.exchangeRateDataService.toCurrency( platforms[currentPlatformId].valueInBaseCurrency
account.balance, )
account.currency, .plus(balanceInBaseCurrency)
userCurrency .toNumber();
);
} else { } else {
platforms[account.platformId || UNKNOWN_KEY] = { platforms[currentPlatformId] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
name: account.platform?.name, name: account.platform?.name,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: balanceInBaseCurrency
account.balance,
account.currency,
userCurrency
)
}; };
} }
} }
for (const { account, assetProfile, quantity, type } of ordersByAccount) { if (ordersByAccount.length === 0) {
const currentValueOfSymbolInBaseCurrency = continue;
getFactor(type) * }
quantity *
(portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? let quantityOfAccount = new Big(0);
0); let valueOfAccountInBaseCurrency = new Big(0);
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[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { if (accounts[currentAccountId]) {
accounts[account?.id || UNKNOWN_KEY].valueInBaseCurrency += accounts[currentAccountId].quantity = quantityOfHolding;
currentValueOfSymbolInBaseCurrency; accounts[currentAccountId].valueInBaseCurrency = new Big(
accounts[currentAccountId].valueInBaseCurrency
)
.plus(valueOfAccountInBaseCurrency)
.toNumber();
} else { } else {
accounts[account?.id || UNKNOWN_KEY] = { accounts[currentAccountId] = {
balance: 0, balance: 0,
currency: account?.currency, currency: account?.currency,
name: account?.name, name: account?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency quantity: quantityOfHolding,
valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber()
}; };
} }
if ( if (platforms[currentPlatformId]) {
platforms[account?.platformId || UNKNOWN_KEY]?.valueInBaseCurrency platforms[currentPlatformId].valueInBaseCurrency = new Big(
) { platforms[currentPlatformId].valueInBaseCurrency
platforms[account?.platformId || UNKNOWN_KEY].valueInBaseCurrency += )
currentValueOfSymbolInBaseCurrency; .plus(valueOfAccountInBaseCurrency)
.toNumber();
} else { } else {
platforms[account?.platformId || UNKNOWN_KEY] = { platforms[currentPlatformId] = {
balance: 0, balance: 0,
currency: account?.currency, currency: account?.currency,
name: account?.platform?.name, name: account?.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber()
}; };
} }
} }
}
return { accounts, platforms }; return { accounts, platforms };
} }

2
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 { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.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'; import { SubscriptionService } from './subscription.service';
@AllowDuringImpersonation()
@Controller('subscription') @Controller('subscription')
export class SubscriptionController { export class SubscriptionController {
private readonly logger = new Logger(SubscriptionController.name); private readonly logger = new Logger(SubscriptionController.name);

44
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 { 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 { CustomThrottlerGuard } from '@ghostfolio/api/guards/custom-throttler.guard';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.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 { decodeDataSource } from '@ghostfolio/api/helper/data-source.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; 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 { 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 { 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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
HEADER_KEY_IMPERSONATION,
THROTTLE_SIGNUP_LIMIT, THROTTLE_SIGNUP_LIMIT,
THROTTLE_SIGNUP_TTL THROTTLE_SIGNUP_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -18,6 +19,7 @@ import {
UpdateOwnAccessTokenDto, UpdateOwnAccessTokenDto,
UpdateUserSettingDto UpdateUserSettingDto
} from '@ghostfolio/common/dtos'; } from '@ghostfolio/common/dtos';
import { isUserSettingOfAuthenticatedUser } from '@ghostfolio/common/helper';
import { import {
AccessTokenResponse, AccessTokenResponse,
User, User,
@ -25,7 +27,10 @@ import {
UserSettings UserSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
ImpersonationContext,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -51,11 +56,11 @@ import { merge, size } from 'lodash';
import { UserService } from './user.service'; import { UserService } from './user.service';
@AllowDuringImpersonation()
@Controller('user') @Controller('user')
export class UserController { export class UserController {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly impersonationService: ImpersonationService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -119,18 +124,15 @@ export class UserController {
} }
@Get() @Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(RedactValuesInResponseInterceptor) @UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getUser( public async getUser(
@Headers('accept-language') acceptLanguage: string, @Headers('accept-language') acceptLanguage: string,
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Impersonation() { isActive, userId }: ImpersonationContext
): Promise<User> { ): Promise<User> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId);
return this.userService.getUser({ return this.userService.getUser({
impersonationUserId, impersonationUserId: isActive ? userId : undefined,
locale: acceptLanguage?.split(',')?.[0], locale: acceptLanguage?.split(',')?.[0],
user: this.request.user user: this.request.user
}); });
@ -167,9 +169,27 @@ export class UserController {
} }
@Put('setting') @Put('setting')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard, ImpersonationGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @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 ( if (
size(data) === 1 && size(data) === 1 &&
(data.benchmark || data.dateRange) && (data.benchmark || data.dateRange) &&

25
apps/api/src/app/user/user.service.ts

@ -41,6 +41,7 @@ import {
THROTTLE_DAILY_TTL THROTTLE_DAILY_TTL
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { resolveUserSettings } from '@ghostfolio/common/helper';
import { import {
User as IUser, User as IUser,
ReferralPartner, ReferralPartner,
@ -58,7 +59,7 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectThrottlerStorage, ThrottlerStorage } from '@nestjs/throttler'; 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 { differenceInDays, subDays } from 'date-fns';
import { isNil, without } from 'lodash'; import { isNil, without } from 'lodash';
import { createHmac } from 'node:crypto'; import { createHmac } from 'node:crypto';
@ -127,7 +128,7 @@ export class UserService {
accounts, accounts,
activitiesCount, activitiesCount,
firstActivity, firstActivity,
impersonationUserSettings, impersonationUser,
tagsForUser tagsForUser
] = await Promise.all([ ] = await Promise.all([
this.prismaService.access.findMany({ this.prismaService.access.findMany({
@ -156,16 +157,16 @@ export class UserService {
where: { userId: impersonationUserId || user.id } where: { userId: impersonationUserId || user.id }
}), }),
impersonationUserId impersonationUserId
? this.prismaService.settings.findUnique({ ? this.user({ id: impersonationUserId })
where: { userId: impersonationUserId } : Promise.resolve<UserWithSettings>(null),
})
: Promise.resolve<Settings>(null),
this.tagService.getTagsForUser(impersonationUserId || user.id) this.tagService.getTagsForUser(impersonationUserId || user.id)
]); ]);
const baseCurrency = const resolvedUserSettings = resolveUserSettings({
(impersonationUserSettings?.settings as UserSettings)?.baseCurrency ?? impersonationUserSettings: impersonationUser?.settings
(settings.settings as UserSettings)?.baseCurrency; ?.settings as UserSettings,
userSettings: settings.settings as UserSettings
});
let referralPartners: ReferralPartner[]; let referralPartners: ReferralPartner[];
@ -220,9 +221,9 @@ export class UserService {
}), }),
dateOfFirstActivity: firstActivity?.date ?? new Date(), dateOfFirstActivity: firstActivity?.date ?? new Date(),
settings: { settings: {
...(settings.settings as UserSettings), ...resolvedUserSettings,
baseCurrency, baseCurrency: resolvedUserSettings.baseCurrency ?? DEFAULT_CURRENCY,
locale: (settings.settings as UserSettings)?.locale ?? locale locale: resolvedUserSettings.locale ?? locale
} }
}; };
} }

12
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);
}

26
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<RequestWithUser>();
return (
impersonation ?? {
isActive: false,
userId: user?.id,
userSettings: user?.settings?.settings ?? {}
}
);
}
);

53
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<boolean>(
ALLOW_DURING_IMPERSONATION_KEY,
[context.getHandler(), context.getClass()]
);
if (isAllowedDuringImpersonation) {
return true;
}
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}

25
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<RequestWithUser>();
request.impersonation = await this.impersonationService.resolve({
impersonationId: request.headers?.[
HEADER_KEY_IMPERSONATION.toLowerCase()
] as string,
user: request.user
});
return true;
}
}

84
apps/api/src/services/impersonation/impersonation.service.ts

@ -1,42 +1,92 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; 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 { 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 { Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
@Injectable() @Injectable()
export class ImpersonationService { export class ImpersonationService {
public constructor( public constructor(private readonly prismaService: PrismaService) {}
private readonly prismaService: PrismaService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public async validateImpersonationId(aId = '') { public async resolve({
if (this.request.user) { impersonationId,
user
}: {
impersonationId?: string;
user?: UserWithSettings;
}): Promise<ImpersonationContext> {
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({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
granteeUserId: this.request.user.id, granteeUserId: user.id,
id: aId id: impersonationId
} }
}); });
if (accessObject?.userId) { if (accessObject?.userId) {
return accessObject.userId; return accessObject.userId;
} else if ( } else if (
hasPermission( hasPermission(user.permissions, permissions.impersonateAllUsers)
this.request.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 { } else {
// Public access // Public access
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
granteeUserId: null, granteeUserId: null,
user: { id: aId } user: { id: impersonationId }
} }
}); });

11
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) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(({ price }) => { .subscribe(({ price }) => {
const currency = this.assetProfileForm.controls.currency.value;
this.notificationService.alert({ this.notificationService.alert({
title: title: `${$localize`The current market price is`} ${price}${
$localize`The current market price is` + currency ? ` ${currency}` : ''
' ' + }`
price +
' ' +
this.assetProfileForm.controls.currency.value
}); });
}); });
} }

2
apps/client/src/app/components/header/header.component.html

@ -217,7 +217,7 @@
<span i18n>Me</span> <span i18n>Me</span>
</span> </span>
</button> </button>
@for (accessItem of user()?.access; track accessItem) { @for (accessItem of user()?.access; track accessItem.id) {
<button mat-menu-item (click)="impersonateAccount(accessItem.id)"> <button mat-menu-item (click)="impersonateAccount(accessItem.id)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon <ion-icon

1
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -418,6 +418,7 @@
[showAllocationInPercentage]="user?.settings?.isExperimentalFeatures" [showAllocationInPercentage]="user?.settings?.isExperimentalFeatures"
[showBalance]="false" [showBalance]="false"
[showFooter]="false" [showFooter]="false"
[showQuantity]="user?.settings?.isExperimentalFeatures"
[showValue]="false" [showValue]="false"
[showValueInBaseCurrency]="false" [showValueInBaseCurrency]="false"
/> />

62
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -3,12 +3,15 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioPosition, PortfolioPosition,
ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { HoldingType, HoldingsViewMode } from '@ghostfolio/common/types'; import {
HoldingType,
HoldingsViewMode,
ToggleOption
} from '@ghostfolio/common/types';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table'; import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfToggleComponent } from '@ghostfolio/ui/toggle'; import { GfToggleComponent } from '@ghostfolio/ui/toggle';
@ -24,11 +27,8 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { Router, RouterModule } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { gridOutline, reorderFourOutline } from 'ionicons/icons'; import { gridOutline, reorderFourOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -36,14 +36,10 @@ import { DeviceDetectorService } from 'ngx-device-detector';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule,
GfHoldingsTableComponent, GfHoldingsTableComponent,
GfToggleComponent, GfToggleComponent,
GfTreemapChartComponent, GfTreemapChartComponent,
IonIcon,
MatButtonModule, MatButtonModule,
MatButtonToggleModule,
ReactiveFormsModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
@ -59,17 +55,29 @@ export class GfHomeHoldingsComponent implements OnInit {
protected hasPermissionToAccessHoldingsChart: boolean; protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean; protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined; protected holdings: PortfolioPosition[] | undefined;
protected holdingsViewMode: HoldingsViewMode =
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE;
protected readonly holdingsViewModeOptions: ToggleOption[] = [
{
iconName: 'reorder-four-outline',
title: $localize`Table`,
value: 'TABLE'
},
{
iconName: 'grid-outline',
title: $localize`Chart`,
value: 'CHART'
}
];
protected holdingType: HoldingType = 'ACTIVE'; protected holdingType: HoldingType = 'ACTIVE';
protected readonly holdingTypeOptions: ToggleOption[] = [ protected readonly holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' }, { label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];
protected isHoldingsViewModeToggleDisabled = true;
protected readonly routerLinkPortfolioActivities = protected readonly routerLinkPortfolioActivities =
internalRoutes.portfolio.subRoutes.activities.routerLink; internalRoutes.portfolio.subRoutes.activities.routerLink;
protected user: User; protected user: User;
protected readonly viewModeFormControl = new FormControl<HoldingsViewMode>(
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
);
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
@ -118,16 +126,13 @@ export class GfHomeHoldingsComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.viewModeFormControl.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((holdingsViewMode) => {
if (!holdingsViewMode) {
return;
} }
protected onChangeHoldingsViewMode(aHoldingsViewMode: HoldingsViewMode) {
this.holdingsViewMode = aHoldingsViewMode;
this.dataService this.dataService
.putUserSetting({ holdingsViewMode }) .putUserSetting({ holdingsViewMode: aHoldingsViewMode })
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {
this.userService this.userService
@ -139,7 +144,6 @@ export class GfHomeHoldingsComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
}); });
});
} }
protected onChangeHoldingType(aHoldingType: HoldingType) { protected onChangeHoldingType(aHoldingType: HoldingType) {
@ -170,26 +174,22 @@ export class GfHomeHoldingsComponent implements OnInit {
} }
private initialize() { private initialize() {
this.viewModeFormControl.disable({ emitEvent: false }); this.isHoldingsViewModeToggleDisabled = true;
if ( if (
this.hasPermissionToAccessHoldingsChart && this.hasPermissionToAccessHoldingsChart &&
this.holdingType === 'ACTIVE' this.holdingType === 'ACTIVE'
) { ) {
this.viewModeFormControl.enable({ emitEvent: false }); this.isHoldingsViewModeToggleDisabled = false;
this.viewModeFormControl.setValue( this.holdingsViewMode =
this.deviceType === 'mobile' this.deviceType === 'mobile'
? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE ? GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
: (this.user?.settings?.holdingsViewMode ?? : (this.user?.settings?.holdingsViewMode ??
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE), GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE);
{ emitEvent: false } } else {
); this.holdingsViewMode =
} else if (this.holdingType === 'CLOSED') { GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE;
this.viewModeFormControl.setValue(
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
{ emitEvent: false }
);
} }
this.holdings = undefined; this.holdings = undefined;

27
apps/client/src/app/components/home-holdings/home-holdings.html

@ -7,32 +7,23 @@
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<div class="d-flex"> <div class="d-flex">
<div class="d-flex"> <gf-toggle
<div class="d-none d-lg-block"> class="d-none d-lg-block"
<mat-button-toggle-group [defaultValue]="holdingsViewMode"
[formControl]="viewModeFormControl" [isDisabled]="isHoldingsViewModeToggleDisabled"
[hideSingleSelectionIndicator]="true" [options]="holdingsViewModeOptions"
> (valueChange)="onChangeHoldingsViewMode($event.value)"
<mat-button-toggle i18n-title title="Table" value="TABLE"> />
<ion-icon name="reorder-four-outline" />
</mat-button-toggle>
<mat-button-toggle i18n-title title="Chart" value="CHART">
<ion-icon name="grid-outline" />
</mat-button-toggle>
</mat-button-toggle-group>
</div>
</div>
<div class="align-items-center d-flex flex-grow-1 justify-content-end"> <div class="align-items-center d-flex flex-grow-1 justify-content-end">
<gf-toggle <gf-toggle
class="d-none d-lg-block" class="d-none d-lg-block"
[defaultValue]="holdingType" [defaultValue]="holdingType"
[isLoading]="false"
[options]="holdingTypeOptions" [options]="holdingTypeOptions"
(valueChange)="onChangeHoldingType($event.value)" (valueChange)="onChangeHoldingType($event.value)"
/> />
</div> </div>
</div> </div>
@if (viewModeFormControl.value === 'CHART') { @if (holdingsViewMode === 'CHART') {
<gf-treemap-chart <gf-treemap-chart
class="mt-3" class="mt-3"
cursor="pointer" cursor="pointer"
@ -44,7 +35,7 @@
(treemapChartClicked)="onHoldingClicked($event)" (treemapChartClicked)="onHoldingClicked($event)"
/> />
} }
<div [class.d-none]="viewModeFormControl.value !== 'TABLE'"> <div [class.d-none]="holdingsViewMode !== 'TABLE'">
<gf-holdings-table <gf-holdings-table
[holdings]="holdings" [holdings]="holdings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

6
apps/client/src/app/components/home-holdings/home-holdings.scss

@ -1,9 +1,3 @@
:host { :host {
display: block; display: block;
.mat-button-toggle-group {
.mat-button-toggle-appearance-standard {
--mat-button-toggle-height: 1.5rem;
}
}
} }

3
apps/client/src/app/components/markets/markets.component.ts

@ -5,11 +5,10 @@ import {
HistoricalDataItem, HistoricalDataItem,
InfoItem, InfoItem,
MarketDataOfMarketsResponse, MarketDataOfMarketsResponse,
ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { FearAndGreedIndexMode } from '@ghostfolio/common/types'; import { FearAndGreedIndexMode, ToggleOption } from '@ghostfolio/common/types';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFearAndGreedIndexComponent } from '@ghostfolio/ui/fear-and-greed-index'; import { GfFearAndGreedIndexComponent } from '@ghostfolio/ui/fear-and-greed-index';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';

58
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -1,3 +1,4 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { import {
KEY_STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,
KEY_TOKEN, KEY_TOKEN,
@ -14,6 +15,7 @@ import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -30,8 +32,9 @@ import {
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { import {
FormControl,
FormGroup,
NonNullableFormBuilder, NonNullableFormBuilder,
FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
@ -58,7 +61,7 @@ import { catchError } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
FormsModule, GfCurrencySelectorComponent,
GfValueComponent, GfValueComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
@ -77,12 +80,18 @@ import { catchError } from 'rxjs/operators';
}) })
export class GfUserAccountSettingsComponent implements OnInit { export class GfUserAccountSettingsComponent implements OnInit {
protected readonly appearancePlaceholder = $localize`Auto`; protected readonly appearancePlaceholder = $localize`Auto`;
protected readonly baseCurrency: string; protected readonly baseCurrencyForm = new FormGroup({
baseCurrency: new FormControl<string | null>({
disabled: true,
value: null
})
});
protected closeUserAccountMailHref: string; protected closeUserAccountMailHref: string;
protected readonly currencies: string[] = []; protected readonly currencies: string[] = [];
protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({ protected readonly deleteOwnUserForm = inject(NonNullableFormBuilder).group({
accessToken: ['', Validators.required] accessToken: ['', Validators.required]
}); });
protected hasImpersonationId: boolean;
protected hasPermissionToDeleteOwnUser: boolean; protected hasPermissionToDeleteOwnUser: boolean;
protected hasPermissionToRequestOwnUserDeletion: boolean; protected hasPermissionToRequestOwnUserDeletion: boolean;
protected hasPermissionToUpdateViewMode: boolean; protected hasPermissionToUpdateViewMode: boolean;
@ -122,6 +131,9 @@ export class GfUserAccountSettingsComponent implements OnInit {
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly settingsStorageService = inject(SettingsStorageService); private readonly settingsStorageService = inject(SettingsStorageService);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
@ -129,11 +141,21 @@ export class GfUserAccountSettingsComponent implements OnInit {
private readonly webAuthnService = inject(WebAuthnService); private readonly webAuthnService = inject(WebAuthnService);
public constructor() { public constructor() {
const { baseCurrency, currencies } = this.dataService.fetchInfo(); const { currencies } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.currencies = currencies; this.currencies = currencies;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.updateBaseCurrencyFormState();
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -182,6 +204,12 @@ export class GfUserAccountSettingsComponent implements OnInit {
permissions.updateViewMode permissions.updateViewMode
); );
this.baseCurrencyForm.setValue({
baseCurrency: this.user.settings.baseCurrency ?? null
});
this.updateBaseCurrencyFormState();
if (this.user.settings.locale) { if (this.user.settings.locale) {
this.locales.push(this.user.settings.locale); this.locales.push(this.user.settings.locale);
} }
@ -194,6 +222,16 @@ export class GfUserAccountSettingsComponent implements OnInit {
} }
}); });
this.baseCurrencyForm.controls.baseCurrency.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((value) => {
// The currency selector emits null while the user is typing and only
// emits a currency once an option has been selected
if (value && value !== this.user?.settings.baseCurrency) {
this.onChangeUserSetting('baseCurrency', value);
}
});
addIcons({ eyeOffOutline, eyeOutline }); addIcons({ eyeOffOutline, eyeOutline });
} }
@ -405,4 +443,14 @@ export class GfUserAccountSettingsComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
private updateBaseCurrencyFormState() {
// The base currency belongs to the impersonated user while a change would be
// applied to the authenticated user
if (!this.hasImpersonationId && this.hasPermissionToUpdateUserSettings) {
this.baseCurrencyForm.enable({ emitEvent: false });
} else {
this.baseCurrencyForm.disable({ emitEvent: false });
}
}
} }

35
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -3,25 +3,30 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="d-flex py-1"> <div class="d-flex py-1">
<form #changeUserSettingsForm="ngForm" class="w-100"> <div class="w-100">
<div class="d-flex mb-2"> <div
class="d-flex"
[class.mb-2]="!hasImpersonationId"
[class.mb-4]="hasImpersonationId"
>
<div class="align-items-center d-flex pt-1 pt-1 w-50"> <div class="align-items-center d-flex pt-1 pt-1 w-50">
<ng-container i18n>Base Currency</ng-container> <ng-container i18n>Base Currency</ng-container>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50" [formGroup]="baseCurrencyForm">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field
<mat-select appearance="outline"
name="baseCurrency" class="w-100"
[disabled]="!hasPermissionToUpdateUserSettings" [class.without-hint]="!hasImpersonationId"
[value]="user.settings.baseCurrency" >
(selectionChange)=" <gf-currency-selector
onChangeUserSetting('baseCurrency', $event.value) formControlName="baseCurrency"
" [currencies]="currencies"
/>
@if (hasImpersonationId) {
<mat-hint class="mt-1 text-muted" i18n
>Derived from the portfolio you are viewing</mat-hint
> >
@for (currency of currencies; track currency) {
<mat-option [value]="currency">{{ currency }}</mat-option>
} }
</mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
@ -214,7 +219,7 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
</form> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50"> <div class="pr-1 w-50">

7
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -13,7 +13,6 @@ import {
PortfolioInvestmentsResponse, PortfolioInvestmentsResponse,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition, PortfolioPosition,
ToggleOption,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
@ -21,7 +20,11 @@ import {
hasReadRestrictedAccessPermission, hasReadRestrictedAccessPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { AiPromptMode, GroupBy } from '@ghostfolio/common/types'; import type {
AiPromptMode,
GroupBy,
ToggleOption
} from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';

2
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -21,7 +21,7 @@
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[projectedTotalAmount]="user?.settings?.projectedTotalAmount" [projectedTotalAmount]="user?.settings?.projectedTotalAmount"
[retirementDate]="user?.settings?.retirementDate" [retirementDate]="user?.settings?.retirementDate"
[savingsRate]="hasImpersonationId ? 0 : user?.settings?.savingsRate" [savingsRate]="user?.settings?.savingsRate"
[style.opacity]=" [style.opacity]="
user?.subscription?.type === 'Basic' ? '0.67' : 'initial' user?.subscription?.type === 'Basic' ? '0.67' : 'initial'
" "

326
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.ja.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

325
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

326
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

4
libs/common/src/lib/config.ts

@ -113,6 +113,7 @@ export const DEFAULT_REDACTED_PATHS = [
'accounts[*].comment', 'accounts[*].comment',
'accounts[*].dividendInBaseCurrency', 'accounts[*].dividendInBaseCurrency',
'accounts[*].interestInBaseCurrency', 'accounts[*].interestInBaseCurrency',
'accounts[*].quantity',
'accounts[*].value', 'accounts[*].value',
'accounts[*].valueInBaseCurrency', 'accounts[*].valueInBaseCurrency',
'activities[*].account.comment', 'activities[*].account.comment',
@ -152,6 +153,9 @@ export const DEFAULT_REDACTED_PATHS = [
'platforms[*].balance', 'platforms[*].balance',
'platforms[*].valueInBaseCurrency', 'platforms[*].valueInBaseCurrency',
'quantity', 'quantity',
'settings.emergencyFund',
'settings.projectedTotalAmount',
'settings.savingsRate',
'totalBalanceInBaseCurrency', 'totalBalanceInBaseCurrency',
'totalDividendInBaseCurrency', 'totalDividendInBaseCurrency',
'totalInterestInBaseCurrency', 'totalInterestInBaseCurrency',

132
libs/common/src/lib/helper.spec.ts

@ -12,8 +12,10 @@ import {
isCurrency, isCurrency,
isCurrencySymbol, isCurrencySymbol,
isSplitRatio, isSplitRatio,
isValidCustomAssetProfileSymbol isValidCustomAssetProfileSymbol,
resolveUserSettings
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { UserSettings } from '@ghostfolio/common/interfaces';
describe('Helper', () => { describe('Helper', () => {
describe('Extract number from string', () => { describe('Extract number from string', () => {
@ -380,4 +382,132 @@ describe('Helper', () => {
).toEqual(true); ).toEqual(true);
}); });
}); });
describe('Resolve user settings', () => {
const userSettings: UserSettings = {
baseCurrency: 'CHF',
colorScheme: 'DARK',
dateRange: '1y',
emergencyFund: 10000,
language: 'de',
locale: 'de-CH',
savingsRate: 500,
viewMode: 'DEFAULT'
};
const impersonationUserSettings: UserSettings = {
baseCurrency: 'USD',
colorScheme: 'LIGHT',
dateRange: 'ytd',
emergencyFund: 25000,
language: 'en',
locale: 'en-US',
savingsRate: 1000,
viewMode: 'ZEN'
};
it('Without impersonation', () => {
expect(
resolveUserSettings({
userSettings,
impersonationUserSettings: undefined
})
).toEqual(userSettings);
});
it('Portfolio settings follow the impersonated user', () => {
const { baseCurrency, emergencyFund, savingsRate } = resolveUserSettings({
impersonationUserSettings,
userSettings
});
expect({ baseCurrency, emergencyFund, savingsRate }).toEqual({
baseCurrency: 'USD',
emergencyFund: 25000,
savingsRate: 1000
});
});
it('Presentation settings stay with the authenticated user', () => {
const { colorScheme, dateRange, language, locale, viewMode } =
resolveUserSettings({ impersonationUserSettings, userSettings });
expect({ colorScheme, dateRange, language, locale, viewMode }).toEqual({
colorScheme: 'DARK',
dateRange: '1y',
language: 'de',
locale: 'de-CH',
viewMode: 'DEFAULT'
});
});
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
const { 'filters.accounts': filtersAccounts } = resolveUserSettings({
impersonationUserSettings: {
'filters.accounts': ['3b3c2b5d-5a4f-4b0a-9d4f-9b1f5e6a7c8d']
},
userSettings: {
'filters.accounts': ['0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d']
}
});
expect(filtersAccounts).toEqual(['0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d']);
});
it('Presentation settings unset for the authenticated user do not leak', () => {
// An unset presentation setting must not fall back to the impersonated
// user, otherwise their appearance and language apply to the
// authenticated user
const { colorScheme, language, locale } = resolveUserSettings({
impersonationUserSettings,
userSettings: { baseCurrency: 'CHF' }
});
expect(colorScheme).toBeUndefined();
expect(language).toBeUndefined();
expect(locale).toBeUndefined();
});
it('Unknown settings default to the impersonated user', () => {
// A setting which is not classified as presentation must not leak from
// the authenticated user into the impersonated portfolio
expect(
resolveUserSettings({
impersonationUserSettings: { annualInterestRate: 5 },
userSettings: { annualInterestRate: 3 }
}).annualInterestRate
).toEqual(5);
});
it('Impersonated user without settings', () => {
expect(
resolveUserSettings({
userSettings,
impersonationUserSettings: {}
})
).toEqual({
colorScheme: 'DARK',
dateRange: '1y',
language: 'de',
locale: 'de-CH',
viewMode: 'DEFAULT'
});
});
});
}); });

53
libs/common/src/lib/helper.ts

@ -51,7 +51,8 @@ import {
AssetProfileIdentifier, AssetProfileIdentifier,
AssetProfileItem, AssetProfileItem,
Benchmark, Benchmark,
PortfolioPosition PortfolioPosition,
UserSettings
} from './interfaces'; } from './interfaces';
import { BenchmarkTrend, ColorScheme } from './types'; import { BenchmarkTrend, ColorScheme } from './types';
@ -59,6 +60,29 @@ export const DATE_FORMAT = 'yyyy-MM-dd';
export const DATE_FORMAT_MONTHLY = 'MMMM yyyy'; export const DATE_FORMAT_MONTHLY = 'MMMM yyyy';
export const DATE_FORMAT_YEARLY = 'yyyy'; export const DATE_FORMAT_YEARLY = 'yyyy';
// Settings which describe the person looking at the screen rather than the
// portfolio being looked at. They stay with the authenticated user while
// impersonating. Every other setting follows the impersonated user.
// 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 USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER: (keyof UserSettings)[] = [
'benchmark',
'colorScheme',
'dateRange',
'filters.accounts',
'filters.assetClasses',
'filters.dataSource',
'filters.symbol',
'filters.tags',
'holdingsViewMode',
'isExperimentalFeatures',
'isRestrictedView',
'language',
'locale',
'viewMode'
];
export function applyAssetProfileOverrides<T extends Partial<SymbolProfile>>( export function applyAssetProfileOverrides<T extends Partial<SymbolProfile>>(
assetProfile: T, assetProfile: T,
assetProfileOverrides: AssetProfileOverrides | null assetProfileOverrides: AssetProfileOverrides | null
@ -584,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) { export function isValidCustomAssetProfileSymbol(aSymbol: string) {
return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol); return hasGhostfolioPrefix(aSymbol) || isUUID(aSymbol);
} }
@ -670,3 +700,24 @@ export function resolveMarketCondition(
return { emoji: undefined }; return { emoji: undefined };
} }
} }
export function resolveUserSettings({
impersonationUserSettings,
userSettings
}: {
impersonationUserSettings?: UserSettings;
userSettings: UserSettings;
}): UserSettings {
if (!impersonationUserSettings) {
return { ...userSettings };
}
return {
...impersonationUserSettings,
...Object.fromEntries(
USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER.map((key) => {
return [key, userSettings?.[key]];
})
)
};
}

2
libs/common/src/lib/interfaces/index.ts

@ -94,7 +94,6 @@ import type { SubscriptionOffer } from './subscription-offer.interface';
import type { SymbolItem } from './symbol-item.interface'; import type { SymbolItem } from './symbol-item.interface';
import type { SymbolMetrics } from './symbol-metrics.interface'; import type { SymbolMetrics } from './symbol-metrics.interface';
import type { SystemMessage } from './system-message.interface'; import type { SystemMessage } from './system-message.interface';
import type { ToggleOption } from './toggle-option.interface';
import type { UserItem } from './user-item.interface'; import type { UserItem } from './user-item.interface';
import type { UserSettings } from './user-settings.interface'; import type { UserSettings } from './user-settings.interface';
import type { User } from './user.interface'; import type { User } from './user.interface';
@ -191,7 +190,6 @@ export {
SymbolItem, SymbolItem,
SymbolMetrics, SymbolMetrics,
SystemMessage, SystemMessage,
ToggleOption,
User, User,
UserItem, UserItem,
UserSettings, UserSettings,

2
libs/common/src/lib/interfaces/portfolio-details.interface.ts

@ -10,6 +10,8 @@ export interface PortfolioDetails {
balance: number; balance: number;
currency: string; currency: string;
name: string; name: string;
/** Only set if the activities are filtered by a single holding */
quantity?: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;
valueInPercentage?: number; valueInPercentage?: number;
}; };

4
libs/common/src/lib/interfaces/toggle-option.interface.ts

@ -1,4 +0,0 @@
export interface ToggleOption {
label: string;
value: string;
}

2
libs/common/src/lib/types/account-with-value.type.ts

@ -9,6 +9,8 @@ export type AccountWithValue = AccountWithBalance & {
dividendInBaseCurrency: number; dividendInBaseCurrency: number;
interestInBaseCurrency: number; interestInBaseCurrency: number;
platform?: Platform; platform?: Platform;
/** Only set if the accounts are filtered by a single holding */
quantity?: number;
tags?: Tag[]; tags?: Tag[];
value: number; value: number;
valueInBaseCurrency: number; valueInBaseCurrency: number;

13
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;
}

4
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 { GroupBy } from './group-by.type';
import type { HoldingType } from './holding-type.type'; import type { HoldingType } from './holding-type.type';
import type { HoldingsViewMode } from './holdings-view-mode.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 { MarketAdvanced } from './market-advanced.type';
import type { MarketDataPreset } from './market-data-preset.type'; import type { MarketDataPreset } from './market-data-preset.type';
import type { MarketState } from './market-state.type'; import type { MarketState } from './market-state.type';
@ -23,6 +24,7 @@ import type { PropertyKey } from './property-key.type';
import type { RequestWithUser } from './request-with-user.type'; import type { RequestWithUser } from './request-with-user.type';
import type { SectorName } from './sector-name.type'; import type { SectorName } from './sector-name.type';
import type { SubscriptionOfferKey } from './subscription-offer-key.type'; import type { SubscriptionOfferKey } from './subscription-offer-key.type';
import type { ToggleOption } from './toggle-option.type';
import type { UserWithSettings } from './user-with-settings.type'; import type { UserWithSettings } from './user-with-settings.type';
import type { ViewMode } from './view-mode.type'; import type { ViewMode } from './view-mode.type';
@ -41,6 +43,7 @@ export type {
GroupBy, GroupBy,
HoldingType, HoldingType,
HoldingsViewMode, HoldingsViewMode,
ImpersonationContext,
Market, Market,
MarketAdvanced, MarketAdvanced,
MarketDataPreset, MarketDataPreset,
@ -52,6 +55,7 @@ export type {
RequestWithUser, RequestWithUser,
SectorName, SectorName,
SubscriptionOfferKey, SubscriptionOfferKey,
ToggleOption,
UserWithSettings, UserWithSettings,
ViewMode ViewMode
}; };

10
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;
};

9
libs/common/src/lib/types/toggle-option.type.ts

@ -0,0 +1,9 @@
interface BaseToggleOption {
value: string;
}
export type ToggleOption = BaseToggleOption &
(
| { iconName: string; label?: never; title: string }
| { iconName?: never; label: string; title?: never }
);

20
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -131,6 +131,26 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="quantity">
<th
*matHeaderCellDef
class="justify-content-end px-1"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Quantity</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale()"
[value]="element.quantity"
/>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="balance"> <ng-container matColumnDef="balance">
<th <th
*matHeaderCellDef *matHeaderCellDef

7
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -66,6 +66,7 @@ export class GfAccountsTableComponent {
public readonly showAllocationInPercentage = input<boolean>(); public readonly showAllocationInPercentage = input<boolean>();
public readonly showBalance = input(true); public readonly showBalance = input(true);
public readonly showFooter = input(true); public readonly showFooter = input(true);
public readonly showQuantity = input<boolean>();
public readonly showValue = input(true); public readonly showValue = input(true);
public readonly showValueInBaseCurrency = input(true); public readonly showValueInBaseCurrency = input(true);
public readonly totalBalanceInBaseCurrency = input<number>(); public readonly totalBalanceInBaseCurrency = input<number>();
@ -103,6 +104,10 @@ export class GfAccountsTableComponent {
columns.push('activitiesCount'); columns.push('activitiesCount');
} }
if (this.showQuantity()) {
columns.push('quantity');
}
if (this.showBalance()) { if (this.showBalance()) {
columns.push('balance'); columns.push('balance');
} }
@ -111,7 +116,9 @@ export class GfAccountsTableComponent {
columns.push('value'); columns.push('value');
} }
if (this.showBalance() || this.showValue()) {
columns.push('currency'); columns.push('currency');
}
if (this.showValueInBaseCurrency()) { if (this.showValueInBaseCurrency()) {
columns.push('valueInBaseCurrency'); columns.push('valueInBaseCurrency');

4
libs/ui/src/lib/carousel/carousel.component.html

@ -1,4 +1,4 @@
@if (this.showPrevArrow) { @if (showPrevArrow) {
<button <button
aria-hidden="true" aria-hidden="true"
aria-label="previous" aria-label="previous"
@ -17,7 +17,7 @@
</div> </div>
</div> </div>
@if (this.showNextArrow) { @if (showNextArrow) {
<button <button
aria-hidden="true" aria-hidden="true"
aria-label="next" aria-label="next"

1
libs/ui/src/lib/currency-selector/currency-selector.component.html

@ -13,6 +13,7 @@
<mat-autocomplete <mat-autocomplete
#currencyAutocomplete="matAutocomplete" #currencyAutocomplete="matAutocomplete"
(closed)="onPanelClosed()"
(optionSelected)="onUpdateCurrency($event)" (optionSelected)="onUpdateCurrency($event)"
> >
@for (currency of filteredCurrencies; track currency) { @for (currency of filteredCurrencies; track currency) {

26
libs/ui/src/lib/currency-selector/currency-selector.component.ts

@ -81,6 +81,7 @@ export class GfCurrencySelectorComponent
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly formField = inject(MAT_FORM_FIELD); private readonly formField = inject(MAT_FORM_FIELD);
private readonly input = viewChild.required(MatInput); private readonly input = viewChild.required(MatInput);
private lastSelectedCurrency: string | null = null;
public constructor( public constructor(
public override readonly _elementRef: ElementRef, public override readonly _elementRef: ElementRef,
@ -110,9 +111,15 @@ export class GfCurrencySelectorComponent
return !this.control.value; return !this.control.value;
} }
public override get value() {
return super.value;
}
public override set value(value: string | null) { public override set value(value: string | null) {
this.control.setValue(value); this.control.setValue(value);
super.value = value; super.value = value;
this.lastSelectedCurrency = value;
} }
public focus() { public focus() {
@ -124,10 +131,6 @@ export class GfCurrencySelectorComponent
} }
public ngOnInit() { public ngOnInit() {
if (this.disabled) {
this.control.disable();
}
const formGroup = this.formGroupDirective.form; const formGroup = this.formGroupDirective.form;
if (formGroup) { if (formGroup) {
@ -170,8 +173,23 @@ export class GfCurrencySelectorComponent
} }
} }
public onPanelClosed() {
// Typing clears the selected currency, so restore the last selection once
// the panel closes without an option having been picked. An empty input is
// left untouched to allow clearing the currency.
if (super.value || !this.control.value) {
return;
}
this.value = this.lastSelectedCurrency;
this.changeDetectorRef.markForCheck();
}
public onUpdateCurrency({ option }: { option: MatOption<string> }) { public onUpdateCurrency({ option }: { option: MatOption<string> }) {
super.value = option.value; super.value = option.value;
this.lastSelectedCurrency = option.value;
} }
private filter(value: string) { private filter(value: string) {

18
libs/ui/src/lib/shared/abstract-mat-form-field.ts

@ -9,7 +9,12 @@ import {
Input, Input,
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import {
ControlValueAccessor,
FormControl,
NgControl,
Validators
} from '@angular/forms';
import { MatFormFieldControl } from '@angular/material/form-field'; import { MatFormFieldControl } from '@angular/material/form-field';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -23,6 +28,7 @@ export abstract class AbstractMatFormField<T>
@HostBinding('attr.aria-describedBy') public describedBy = ''; @HostBinding('attr.aria-describedBy') public describedBy = '';
public readonly autofilled: boolean; public readonly autofilled: boolean;
public abstract readonly control: FormControl;
public errorState: boolean; public errorState: boolean;
public focused = false; public focused = false;
public readonly stateChanges = new Subject<void>(); public readonly stateChanges = new Subject<void>();
@ -156,6 +162,16 @@ export abstract class AbstractMatFormField<T>
this.describedBy = ids.join(' '); this.describedBy = ids.join(' ');
} }
public setDisabledState(isDisabled: boolean) {
if (isDisabled) {
this.control.disable({ emitEvent: false });
} else {
this.control.enable({ emitEvent: false });
}
this.stateChanges.next();
}
public writeValue(value: T) { public writeValue(value: T) {
this.value = value; this.value = value;
} }

4
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -115,10 +115,6 @@ export class GfSymbolAutocompleteComponent
} }
public ngOnInit() { public ngOnInit() {
if (this.disabled) {
this.control.disable();
}
this.control.valueChanges this.control.valueChanges
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {

14
libs/ui/src/lib/toggle/toggle.component.html

@ -9,11 +9,19 @@
[class]="{ [class]="{
'cursor-default': option.value === optionFormControl.value, 'cursor-default': option.value === optionFormControl.value,
'cursor-pointer': 'cursor-pointer':
!isLoading() && option.value !== optionFormControl.value !isDisabled() &&
!isLoading() &&
option.value !== optionFormControl.value
}" }"
[disabled]="isLoading()" [disabled]="isDisabled() || isLoading()"
[title]="option.title ?? ''"
[value]="option.value" [value]="option.value"
>{{ option.label }}</mat-radio-button
> >
@if (option.iconName) {
<ion-icon class="d-block" [name]="option.iconName" />
} @else {
<span>{{ option.label }}</span>
}
</mat-radio-button>
} }
</mat-radio-group> </mat-radio-group>

16
libs/ui/src/lib/toggle/toggle.component.scss

@ -9,6 +9,14 @@
background-color: rgba(var(--dark-dividers)); background-color: rgba(var(--dark-dividers));
} }
&.mat-mdc-radio-disabled {
::ng-deep {
label {
color: rgba(var(--dark-disabled-text));
}
}
}
::ng-deep { ::ng-deep {
.mdc-radio { .mdc-radio {
display: none; display: none;
@ -31,6 +39,14 @@
border: 1px solid rgba(var(--light-disabled-text)); border: 1px solid rgba(var(--light-disabled-text));
} }
&.mat-mdc-radio-disabled {
::ng-deep {
label {
color: rgba(var(--light-disabled-text));
}
}
}
::ng-deep { ::ng-deep {
label { label {
color: rgba(var(--light-primary-text), 1); color: rgba(var(--light-primary-text), 1);

43
libs/ui/src/lib/toggle/toggle.component.stories.ts

@ -1,17 +1,22 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ReactiveFormsModule } from '@angular/forms'; import { ReactiveFormsModule } from '@angular/forms';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { addIcons } from 'ionicons';
import { gridOutline, reorderFourOutline } from 'ionicons/icons';
import { GfToggleComponent } from './toggle.component'; import { GfToggleComponent } from './toggle.component';
addIcons({ gridOutline, reorderFourOutline });
export default { export default {
title: 'Toggle', title: 'Toggle',
component: GfToggleComponent, component: GfToggleComponent,
decorators: [ decorators: [
moduleMetadata({ moduleMetadata({
imports: [CommonModule, MatRadioModule, ReactiveFormsModule] imports: [CommonModule, IonIcon, MatRadioModule, ReactiveFormsModule]
}) })
] ]
} as Meta<GfToggleComponent>; } as Meta<GfToggleComponent>;
@ -21,6 +26,22 @@ type Story = StoryObj<GfToggleComponent>;
export const Default: Story = { export const Default: Story = {
args: { args: {
defaultValue: '1d', defaultValue: '1d',
isDisabled: false,
isLoading: false,
options: [
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
{ label: '1Y', value: '1y' },
{ label: '5Y', value: '5y' },
{ label: 'Max', value: 'max' }
]
}
};
export const Disabled: Story = {
args: {
defaultValue: '1d',
isDisabled: true,
isLoading: false, isLoading: false,
options: [ options: [
{ label: 'Today', value: '1d' }, { label: 'Today', value: '1d' },
@ -31,3 +52,23 @@ export const Default: Story = {
] ]
} }
}; };
export const WithIcons: Story = {
args: {
defaultValue: 'TABLE',
isDisabled: false,
isLoading: false,
options: [
{
iconName: 'reorder-four-outline',
title: 'Table',
value: 'TABLE'
},
{
iconName: 'grid-outline',
title: 'Chart',
value: 'CHART'
}
]
}
};

6
libs/ui/src/lib/toggle/toggle.component.ts

@ -1,4 +1,4 @@
import { ToggleOption } from '@ghostfolio/common/interfaces'; import { ToggleOption } from '@ghostfolio/common/types';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -9,16 +9,18 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { MatRadioModule } from '@angular/material/radio'; import { MatRadioModule } from '@angular/material/radio';
import { IonIcon } from '@ionic/angular/standalone';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatRadioModule, ReactiveFormsModule], imports: [IonIcon, MatRadioModule, ReactiveFormsModule],
selector: 'gf-toggle', selector: 'gf-toggle',
styleUrls: ['./toggle.component.scss'], styleUrls: ['./toggle.component.scss'],
templateUrl: './toggle.component.html' templateUrl: './toggle.component.html'
}) })
export class GfToggleComponent { export class GfToggleComponent {
public readonly defaultValue = input.required<string>(); public readonly defaultValue = input.required<string>();
public readonly isDisabled = input<boolean>(false);
public readonly isLoading = input<boolean>(false); public readonly isLoading = input<boolean>(false);
public readonly options = input<ToggleOption[]>([]); public readonly options = input<ToggleOption[]>([]);

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.46.0", "version": "3.48.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.46.0", "version": "3.48.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.46.0", "version": "3.48.1",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

Loading…
Cancel
Save