Browse Source

Bugfix/percentage values in impersonation mode with unrestricted access (#7522)

* Fix percentage values in impersonation mode with unrestricted access

* Fix savings rate in impersonation mode

* Improve savingsRate on FIRE page

* Update changelog
pull/7427/head^2
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
4386d22e53
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 30
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 5
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 2
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  5. 10
      apps/client/src/app/app.component.ts
  6. 11
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  7. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  8. 2
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  9. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  10. 2
      apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts
  11. 12
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  12. 23
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  13. 34
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  14. 12
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  15. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  16. 1
      libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts
  17. 12
      libs/common/src/lib/permissions.ts

6
CHANGELOG.md

@ -20,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Migrated the abstract _Material_ form field from a component to a directive
- Removed the redundant `balance` attribute of the account in favor of the account balances
### Fixed
- Fixed the values of the charts and tables in impersonation mode with an unrestricted access to show absolute values instead of percentages
- Fixed the savings rate of the investment timeline chart and the streaks on the analysis page in impersonation mode to be based on the impersonated user
- Fixed the savings rate of the _FIRE_ calculator in impersonation mode to not be based on the impersonating user
## 3.43.0 - 2026-08-06
### Added

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

@ -136,7 +136,7 @@ export class PortfolioController {
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -180,7 +180,7 @@ export class PortfolioController {
hasDetails === false ||
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -374,7 +374,7 @@ export class PortfolioController {
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -491,19 +491,19 @@ export class PortfolioController {
filterByTags: tags
});
let { investments, streaks } = await this.portfolioService.getInvestments({
filters,
groupBy,
impersonationId,
dateRange: range,
savingsRate: this.request.user?.settings?.settings.savingsRate,
userId: this.request.user.id
});
let { investments, savingsRate, streaks } =
await this.portfolioService.getInvestments({
filters,
groupBy,
impersonationId,
dateRange: range,
userId: this.request.user.id
});
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user)
) {
@ -521,6 +521,8 @@ export class PortfolioController {
'currentStreak',
'longestStreak'
]);
savingsRate = null;
}
if (
@ -537,7 +539,7 @@ export class PortfolioController {
]);
}
return { investments, streaks };
return { investments, savingsRate, streaks };
}
@Get('performance')
@ -578,7 +580,7 @@ export class PortfolioController {
if (
hasReadRestrictedAccessPermission({
impersonationId,
user: this.request.user
accesses: this.request.user?.accessesGet
}) ||
isRestrictedView(this.request.user) ||
this.request.user.settings.settings.viewMode === 'ZEN'

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

@ -413,19 +413,18 @@ export class PortfolioService {
filters,
groupBy,
impersonationId,
savingsRate,
userId
}: {
dateRange: DateRange;
filters?: Filter[];
groupBy?: GroupBy;
impersonationId: string;
savingsRate: number;
userId: string;
}): Promise<PortfolioInvestmentsResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const savingsRate = (user.settings?.settings as UserSettings)?.savingsRate;
const { endDate, startDate } = getIntervalFromDateRange({ dateRange });
@ -438,6 +437,7 @@ export class PortfolioService {
if (activities.length === 0) {
return {
savingsRate,
investments: [],
streaks: { currentStreak: 0, longestStreak: 0 }
};
@ -484,6 +484,7 @@ export class PortfolioService {
return {
investments,
savingsRate,
streaks
};
}

2
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -38,7 +38,7 @@ export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
if (
hasReadRestrictedAccessPermission({
impersonationId,
user
accesses: user?.accessesGet
}) ||
isRestrictedView(user)
) {

10
apps/client/src/app/app.component.ts

@ -57,12 +57,12 @@ export class GfAppComponent implements OnInit {
public currentRoute: string;
public currentSubRoute: string;
public deviceType: string;
public hasImpersonationId: boolean;
public hasInfoMessage: boolean;
public hasPermissionToChangeDateRange: boolean;
public hasPermissionToChangeFilters: boolean;
public hasPromotion = false;
public hasTabs = false;
public impersonationId: string | null;
public info: InfoItem;
public pageTitle: string;
public routerLinkRegister = publicRoutes.register.routerLink;
@ -116,7 +116,7 @@ export class GfAppComponent implements OnInit {
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
});
this.router.events
@ -291,13 +291,12 @@ export class GfAppComponent implements OnInit {
baseCurrency: this.user?.settings?.baseCurrency,
colorScheme: this.user?.settings?.colorScheme,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToAccessAdminControl: hasPermission(
this.user?.permissions,
permissions.accessAdminControl
),
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
!this.impersonationId &&
hasPermission(
this.user?.permissions,
permissions.createActivity
@ -308,12 +307,13 @@ export class GfAppComponent implements OnInit {
permissions.reportDataGlitch
),
hasPermissionToUpdateActivity:
!this.hasImpersonationId &&
!this.impersonationId &&
hasPermission(
this.user?.permissions,
permissions.updateActivity
) &&
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '98vh' : '80vh',

11
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -14,7 +14,11 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
hasPermission,
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { GfAccountBalancesComponent } from '@ghostfolio/ui/account-balances';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer';
@ -225,7 +229,10 @@ export class GfAccountDetailDialogComponent implements OnInit {
protected showValuesInPercentage() {
return (
this.data.hasImpersonationId || this.user?.settings?.isRestrictedView
hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.data.impersonationId
}) || this.user?.settings?.isRestrictedView
);
}

4
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -158,8 +158,8 @@
[pageSize]="pageSize"
[showAccountColumn]="false"
[showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity &&
!data.impersonationId &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView
"
@ -183,8 +183,8 @@
[currentBalance]="balance"
[locale]="user?.settings?.locale"
[showActions]="
!data.hasImpersonationId &&
hasPermissionToDeleteAccountBalance &&
!data.impersonationId &&
!user.settings.isRestrictedView
"
(accountBalanceCreated)="onAddAccountBalance($event)"

2
apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts

@ -1,8 +1,8 @@
export interface AccountDetailDialogParams {
accountId: string;
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToCreateActivity: boolean;
impersonationId: string | null;
}
export interface AccountDetailDialogResult {

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

@ -382,7 +382,7 @@
[hasPermissionToCreateActivity]="false"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="
!data.hasImpersonationId && !user?.settings?.isRestrictedView
!data.impersonationId && !user?.settings?.isRestrictedView
"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
@ -390,8 +390,8 @@
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showActions]="
!data.hasImpersonationId &&
data.hasPermissionToCreateActivity &&
!data.impersonationId &&
user?.settings?.isExperimentalFeatures &&
!user?.settings?.isRestrictedView
"

2
apps/client/src/app/components/holding-detail-dialog/interfaces/interfaces.ts

@ -7,11 +7,11 @@ export interface HoldingDetailDialogParams {
colorScheme: ColorScheme;
dataSource: DataSource;
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToAccessAdminControl: boolean;
hasPermissionToCreateActivity: boolean;
hasPermissionToReportDataGlitch: boolean;
hasPermissionToUpdateActivity: boolean;
impersonationId: string | null;
locale: string;
symbol: string;
}

12
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -51,9 +51,9 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba
export class GfAccountsPageComponent implements OnInit {
protected accounts: AccountWithValue[];
protected activitiesCount = 0;
protected hasImpersonationId: boolean;
protected hasPermissionToCreateAccount: boolean;
protected hasPermissionToUpdateAccount: boolean;
protected impersonationId: string | null;
protected totalBalanceInBaseCurrency = 0;
protected totalValueInBaseCurrency = 0;
protected user: User;
@ -104,12 +104,16 @@ export class GfAccountsPageComponent implements OnInit {
});
}
protected get hasImpersonationId() {
return !!this.impersonationId;
}
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
});
this.userService.stateChanged
@ -252,11 +256,11 @@ export class GfAccountsPageComponent implements OnInit {
data: {
accountId: aAccountId,
deviceType: this.deviceType(),
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'

23
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -17,7 +17,11 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
hasPermission,
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import { MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
@ -85,7 +89,6 @@ export class GfAllocationsPageComponent implements OnInit {
protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
protected hasImpersonationId: boolean;
protected holdings: {
[symbol: string]: Pick<
PortfolioPosition['assetProfile'],
@ -97,6 +100,7 @@ export class GfAllocationsPageComponent implements OnInit {
| 'name'
> & { etfProvider: string; value: number };
};
protected impersonationId: string | null;
protected isLoading = false;
protected markets: PortfolioDetails['markets'];
protected marketsAdvanced: {
@ -169,7 +173,7 @@ export class GfAllocationsPageComponent implements OnInit {
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
this.changeDetectorRef.markForCheck();
});
@ -224,7 +228,12 @@ export class GfAllocationsPageComponent implements OnInit {
}
protected showValuesInPercentage() {
return this.hasImpersonationId || this.user?.settings?.isRestrictedView;
return (
hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.impersonationId
}) || this.user?.settings?.isRestrictedView
);
}
private extractCurrency({
@ -618,11 +627,11 @@ export class GfAllocationsPageComponent implements OnInit {
data: {
accountId: aAccountId,
deviceType: this.deviceType(),
hasImpersonationId: this.hasImpersonationId,
hasPermissionToCreateActivity:
!this.hasImpersonationId &&
!this.impersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'

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

@ -16,7 +16,11 @@ import {
ToggleOption,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
hasPermission,
hasReadRestrictedAccessPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { AiPromptMode, GroupBy } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -79,8 +83,8 @@ export class GfAnalysisPageComponent implements OnInit {
protected bottom3: PortfolioPosition[];
protected dividendsByGroup: InvestmentItem[];
protected readonly dividendTimelineDataLabel = $localize`Dividend`;
protected hasImpersonationId: boolean;
protected hasPermissionToReadAiPrompt: boolean;
protected impersonationId: string | null;
protected investments: InvestmentItem[];
protected readonly investmentTimelineDataLabel = $localize`Invested Capital`;
protected investmentsByGroup: InvestmentItem[];
@ -100,6 +104,7 @@ export class GfAnalysisPageComponent implements OnInit {
protected performanceDataItemsInPercentage: HistoricalDataItem[];
protected readonly portfolioEvolutionDataLabel = $localize`Investment`;
protected precision = 2;
protected savingsRatePerMonth: number | undefined;
protected streaks: PortfolioInvestmentsResponse['streaks'];
protected top3: PortfolioPosition[];
protected unitCurrentStreak: string;
@ -131,18 +136,13 @@ export class GfAnalysisPageComponent implements OnInit {
}
get savingsRate() {
const savingsRatePerMonth =
this.hasImpersonationId || this.user.settings.isRestrictedView
? undefined
: this.user?.settings?.savingsRate;
if (savingsRatePerMonth === undefined) {
if (!this.savingsRatePerMonth) {
return undefined;
}
return this.mode() === 'year'
? savingsRatePerMonth * 12
: savingsRatePerMonth;
? this.savingsRatePerMonth * 12
: this.savingsRatePerMonth;
}
public ngOnInit() {
@ -150,7 +150,7 @@ export class GfAnalysisPageComponent implements OnInit {
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.impersonationId = impersonationId;
this.changeDetectorRef.markForCheck();
});
@ -241,6 +241,15 @@ export class GfAnalysisPageComponent implements OnInit {
});
}
protected showValuesInPercentage() {
return (
hasReadRestrictedAccessPermission({
accesses: this.user?.access,
impersonationId: this.impersonationId
}) || this.user?.settings?.isRestrictedView
);
}
private fetchDividendsAndInvestments() {
this.isLoadingDividendTimelineChart = true;
this.isLoadingInvestmentTimelineChart = true;
@ -267,8 +276,9 @@ export class GfAnalysisPageComponent implements OnInit {
range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ investments, streaks }) => {
.subscribe(({ investments, savingsRate, streaks }) => {
this.investmentsByGroup = investments;
this.savingsRatePerMonth = savingsRate;
this.streaks = streaks;
this.unitCurrentStreak =
this.mode() === 'year'

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

@ -398,9 +398,7 @@
[benchmarkDataLabel]="portfolioEvolutionDataLabel"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="performanceDataItems"
[isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
[isInPercentage]="showValuesInPercentage()"
[isLoading]="isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
/>
@ -456,9 +454,7 @@
[benchmarkDataLabel]="investmentTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode()"
[isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
[isInPercentage]="showValuesInPercentage()"
[isLoading]="isLoadingInvestmentTimelineChart"
[locale]="user?.settings?.locale"
[savingsRate]="savingsRate"
@ -493,9 +489,7 @@
[benchmarkDataLabel]="dividendTimelineDataLabel"
[currency]="user?.settings?.baseCurrency"
[groupBy]="mode()"
[isInPercentage]="
hasImpersonationId || user.settings.isRestrictedView
"
[isInPercentage]="showValuesInPercentage()"
[isLoading]="isLoadingDividendTimelineChart"
[locale]="user?.settings?.locale"
/>

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

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

1
libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts

@ -2,5 +2,6 @@ import { InvestmentItem } from '../investment-item.interface';
export interface PortfolioInvestmentsResponse {
investments: InvestmentItem[];
savingsRate?: number;
streaks: { currentStreak: number; longestStreak: number };
}

12
libs/common/src/lib/permissions.ts

@ -1,6 +1,6 @@
import { UserWithSettings } from '@ghostfolio/common/types';
import { Role } from '@prisma/client';
import { Access, Role } from '@prisma/client';
export const permissions = {
accessAdminControl: 'accessAdminControl',
@ -198,17 +198,17 @@ export function hasPermission(
}
export function hasReadRestrictedAccessPermission({
impersonationId,
user
accesses = [],
impersonationId
}: {
impersonationId: string;
user: UserWithSettings;
accesses?: Pick<Access, 'id' | 'permissions'>[];
impersonationId: string | null;
}) {
if (!impersonationId) {
return false;
}
const access = user?.accessesGet?.find(({ id }) => {
const access = accesses.find(({ id }) => {
return id === impersonationId;
});

Loading…
Cancel
Save