Browse Source

Bugfix/user settings and calculations in impersonation mode (#7592)

* Fix user settings and calculations in impersonation mode

* Update changelog
pull/7594/head^2
Thomas Kaul 7 days ago
committed by GitHub
parent
commit
fbd898b32d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      CHANGELOG.md
  2. 31
      apps/api/src/app/activities/activities.controller.ts
  3. 2
      apps/api/src/app/endpoints/public/public.controller.ts
  4. 3
      apps/api/src/app/portfolio/portfolio.controller.ts
  5. 30
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 26
      apps/api/src/app/user/user.service.ts
  7. 14
      apps/api/src/services/impersonation/impersonation.service.ts
  8. 5
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  9. 1
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  10. 32
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  11. 1
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  12. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  13. 118
      libs/common/src/lib/helper.spec.ts
  14. 46
      libs/common/src/lib/helper.ts

14
CHANGELOG.md

@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Fixed
- 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 selector of the performance chart on the analysis page in impersonation mode to be disabled
- Fixed the emergency fund of the _X-ray_ page in impersonation mode to be based on the impersonated user
- 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 ## 3.47.0 - 2026-08-10
### Changed ### Changed

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

@ -7,16 +7,19 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { 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, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { import {
ActivitiesResponse, ActivitiesResponse,
ActivityResponse ActivityResponse,
UserSettings
} 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 { RequestWithUser } from '@ghostfolio/common/types';
@ -54,6 +57,7 @@ export class ActivitiesController {
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly prismaService: PrismaService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -169,8 +173,9 @@ export class ActivitiesController {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userId = impersonationUserId || this.request.user.id;
const userCurrency = this.request.user.settings.settings.baseCurrency; const userCurrency = await this.getUserCurrency(impersonationUserId);
const { activities, count } = await this.activitiesService.getActivities({ const { activities, count } = await this.activitiesService.getActivities({
endDate, endDate,
@ -181,9 +186,9 @@ export class ActivitiesController {
startDate, startDate,
take, take,
userCurrency, userCurrency,
userId,
includeDrafts: true, includeDrafts: true,
types: activityTypes, types: activityTypes,
userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -200,12 +205,14 @@ export class ActivitiesController {
): Promise<ActivityResponse> { ): Promise<ActivityResponse> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId(impersonationId); await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.settings.settings.baseCurrency; const userId = impersonationUserId || this.request.user.id;
const userCurrency = await this.getUserCurrency(impersonationUserId);
const { activities } = await this.activitiesService.getActivities({ const { activities } = await this.activitiesService.getActivities({
userCurrency, userCurrency,
userId,
includeDrafts: true, includeDrafts: true,
userId: impersonationUserId || this.request.user.id,
withExcludedAccountsAndActivities: true withExcludedAccountsAndActivities: true
}); });
@ -377,4 +384,18 @@ export class ActivitiesController {
} }
}); });
} }
private async getUserCurrency(impersonationUserId: string) {
if (!impersonationUserId) {
return this.request.user.settings.settings.baseCurrency;
}
const settings = await this.prismaService.settings.findUnique({
where: { userId: impersonationUserId }
});
return (
(settings?.settings as UserSettings)?.baseCurrency ?? DEFAULT_CURRENCY
);
}
} }

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

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

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

@ -368,7 +368,8 @@ export class PortfolioController {
let dividends = this.portfolioService.getDividends({ let dividends = this.portfolioService.getDividends({
activities, activities,
groupBy groupBy,
userCurrency
}); });
if ( if (

30
apps/api/src/app/portfolio/portfolio.service.ts

@ -45,7 +45,8 @@ import {
getSum, getSum,
isAccountExcluded, isAccountExcluded,
isDraftActivity, isDraftActivity,
parseDate parseDate,
resolveUserSettings
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AccountsResponse, AccountsResponse,
@ -195,10 +196,10 @@ export class PortfolioService {
orderBy: { name: 'asc' } orderBy: { name: 'asc' }
}), }),
this.getDetails({ this.getDetails({
userId,
withExcludedAccounts, withExcludedAccounts,
filters: filtersWithoutSearchQueryFilter, filters: filtersWithoutSearchQueryFilter,
impersonationId: userId, impersonationId: undefined
userId: this.request.user.id
}), }),
this.userService.user({ id: userId }) this.userService.user({ id: userId })
]); ]);
@ -355,10 +356,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 +369,7 @@ export class PortfolioService {
investment: this.exchangeRateDataService.toCurrency( investment: this.exchangeRateDataService.toCurrency(
value, value,
currency, currency,
this.getUserCurrency() userCurrency
) )
}; };
}); });
@ -1142,7 +1145,16 @@ export class PortfolioService {
userId: string; userId: string;
}): Promise<PortfolioReportResponse> { }): Promise<PortfolioReportResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const userSettings = this.request.user.settings.settings as UserSettings;
const user = await this.userService.user({ id: userId });
// The rules are evaluated against the portfolio of the (potentially
// impersonated) user, while the translations follow the language of the
// authenticated user
const userSettings = resolveUserSettings({
impersonationUserSettings: user?.settings?.settings as UserSettings,
userSettings: this.request.user.settings.settings as UserSettings
});
const { accounts, holdings, markets, marketsAdvanced, summary } = const { accounts, holdings, markets, marketsAdvanced, summary } =
await this.getDetails({ await this.getDetails({
@ -2148,11 +2160,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) { private async getUserId(aImpersonationId: string, aUserId: string) {

26
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,17 @@ 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: impersonationUserId
(settings.settings as UserSettings)?.baseCurrency; ? ((impersonationUser?.settings?.settings ?? {}) as UserSettings)
: undefined,
userSettings: settings.settings as UserSettings
});
let referralPartners: ReferralPartner[]; let referralPartners: ReferralPartner[];
@ -220,9 +222,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
} }
}; };
} }

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

@ -12,7 +12,11 @@ export class ImpersonationService {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
public async validateImpersonationId(aId = '') { public async validateImpersonationId(aId?: string) {
if (!aId) {
return null;
}
if (this.request.user) { if (this.request.user) {
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
@ -29,7 +33,13 @@ export class ImpersonationService {
permissions.impersonateAllUsers permissions.impersonateAllUsers
) )
) { ) {
return aId; // The identifier is a user id in this case, hence verify its existence
const user = await this.prismaService.user.findUnique({
select: { id: true },
where: { id: aId }
});
return user?.id ?? null;
} }
} else { } else {
// Public access // Public access

5
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html

@ -18,7 +18,10 @@
<mat-label i18n>Compare with...</mat-label> <mat-label i18n>Compare with...</mat-label>
<mat-select <mat-select
name="benchmark" name="benchmark"
[disabled]="user()?.subscription?.type === 'Basic'" [disabled]="
!hasPermissionToUpdateUserSettings() ||
user()?.subscription?.type === 'Basic'
"
[value]="benchmark()?.id" [value]="benchmark()?.id"
(selectionChange)="onChangeBenchmark($event.value)" (selectionChange)="onChangeBenchmark($event.value)"
> >

1
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -69,6 +69,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public readonly benchmarkDataItems = input<LineChartItem[]>([]); public readonly benchmarkDataItems = input<LineChartItem[]>([]);
public readonly benchmarks = input<Partial<SymbolProfile>[]>(); public readonly benchmarks = input<Partial<SymbolProfile>[]>();
public readonly colorScheme = input.required<ColorScheme>(); public readonly colorScheme = input.required<ColorScheme>();
public readonly hasPermissionToUpdateUserSettings = input<boolean>();
public readonly isLoading = input<boolean>(); public readonly isLoading = input<boolean>();
public readonly locale = input(getLocale()); public readonly locale = input(getLocale());
public readonly performanceDataItems = input.required<LineChartItem[]>(); public readonly performanceDataItems = input.required<LineChartItem[]>();

32
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,
@ -90,6 +91,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
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;
@ -129,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);
@ -140,6 +145,17 @@ export class GfUserAccountSettingsComponent implements OnInit {
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) => {
@ -192,11 +208,7 @@ export class GfUserAccountSettingsComponent implements OnInit {
baseCurrency: this.user.settings.baseCurrency ?? null baseCurrency: this.user.settings.baseCurrency ?? null
}); });
if (this.hasPermissionToUpdateUserSettings) { this.updateBaseCurrencyFormState();
this.baseCurrencyForm.enable({ emitEvent: false });
} else {
this.baseCurrencyForm.disable({ emitEvent: false });
}
if (this.user.settings.locale) { if (this.user.settings.locale) {
this.locales.push(this.user.settings.locale); this.locales.push(this.user.settings.locale);
@ -431,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 });
}
}
} }

1
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -137,6 +137,7 @@
[benchmarkDataItems]="benchmarkDataItems" [benchmarkDataItems]="benchmarkDataItems"
[benchmarks]="benchmarks" [benchmarks]="benchmarks"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[hasPermissionToUpdateUserSettings]="!impersonationId"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart" [isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItemsInPercentage" [performanceDataItems]="performanceDataItemsInPercentage"

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'
" "

118
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,118 @@ 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('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({
userSettings: { annualInterestRate: 3 },
impersonationUserSettings: { annualInterestRate: 5 }
}).annualInterestRate
).toEqual(5);
});
it('Impersonated user without settings', () => {
expect(
resolveUserSettings({
userSettings,
impersonationUserSettings: {}
})
).toEqual({
colorScheme: 'DARK',
dateRange: '1y',
language: 'de',
locale: 'de-CH',
viewMode: 'DEFAULT'
});
});
});
}); });

46
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,28 @@ 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 PRESENTATION_USER_SETTINGS_KEYS: (keyof UserSettings)[] = [
'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
@ -670,3 +693,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(
PRESENTATION_USER_SETTINGS_KEYS.map((key) => {
return [key, userSettings?.[key]];
})
)
};
}

Loading…
Cancel
Save