Browse Source

Merge branch 'main' into bugfix/truncate-long-names-in-analysis-page

pull/8057/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
84c2975fba
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 47
      README.md
  3. 44
      apps/api/src/app/activities/activities.service.spec.ts
  4. 14
      apps/api/src/app/activities/activities.service.ts
  5. 16
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  6. 6
      apps/api/src/app/portfolio/current-rate.service.ts
  7. 104
      apps/api/src/helper/security-headers.helper.spec.ts
  8. 37
      apps/api/src/helper/security-headers.helper.ts
  9. 30
      apps/api/src/main.ts
  10. 3
      apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts
  11. 6
      apps/api/src/models/rules/liquidity/buying-power.ts
  12. 3
      apps/api/src/services/configuration/configuration.service.ts
  13. 1
      apps/api/src/services/interfaces/environment.interface.ts
  14. 3
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  15. 3
      libs/common/src/lib/chart-helper.ts
  16. 6
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  17. 3
      libs/ui/src/lib/line-chart/line-chart.component.ts
  18. 3
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts
  19. 15
      libs/ui/src/lib/treemap-chart/treemap-chart.component.ts
  20. 7
      libs/ui/src/lib/value/value.component.ts

6
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Exposed the `ENABLE_FEATURE_SECURITY_HEADERS` environment variable to enable HTTP security headers (experimental)
### Changed ### Changed
- Upgraded `Nx` from version `23.1.1` to `23.2.1` - Upgraded `Nx` from version `23.1.1` to `23.2.1`
@ -14,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed a layout issue in the top and bottom holdings of the analysis page by truncating long names - Fixed a layout issue in the top and bottom holdings of the analysis page by truncating long names
- Fixed the missing thousands separator of 4-digit numbers in certain locales
- Fixed an issue where holdings without a market price have been valued at the unit price of a dividend, a fee, an interest or a liability
## 3.79.0 - 2026-10-04 ## 3.79.0 - 2026-10-04

47
README.md

@ -87,29 +87,30 @@ Find answers to commonly asked questions about self-hosting Ghostfolio in our [F
### Supported Environment Variables ### Supported Environment Variables
| Name | Type | Default Value | Description | | Name | Type | Default Value | Description |
| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | | `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | | `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key |
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | | `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | | `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` |
| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | | `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) |
| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | | `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token |
| `ENABLE_FEATURE_MCP` | `boolean` (optional) | `false` | Enables the server of the _Model Context Protocol_ (MCP) at `/mcp` (experimental) | | `ENABLE_FEATURE_MCP` | `boolean` (optional) | `false` | Enables the server of the _Model Context Protocol_ (MCP) at `/mcp` (experimental) |
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | | `ENABLE_FEATURE_SECURITY_HEADERS` | `boolean` (optional) | `false` | Enables HTTP security headers, e.g. `Content-Security-Policy` and `X-Frame-Options` (experimental). Other origins cannot embed pages (e.g. via `iframe`) or resources of Ghostfolio. `Strict-Transport-Security` is not set, because it requires HTTPS: set it in the reverse proxy |
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | | `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | | `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | | `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` |
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | | `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | | `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | | `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `string` | | The host where _Redis_ is running | | `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | | `REDIS_HOST` | `string` | | The host where _Redis_ is running |
| `REDIS_PORT` | `number` | | The port where _Redis_ is running | | `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | | `REDIS_PORT` | `number` | | The port where _Redis_ is running |
| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | | `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy | | `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. |
| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy |
#### OpenID Connect OIDC (experimental) #### OpenID Connect OIDC (experimental)

44
apps/api/src/app/activities/activities.service.spec.ts

@ -3,6 +3,7 @@ import {
activityDummyData, activityDummyData,
assetProfileDummyData assetProfileDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { WHERE_ACTIVITY_NOT_DRAFT } from '@ghostfolio/api/helper/activity.helper';
import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -14,7 +15,11 @@ import {
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Activity, Filter } from '@ghostfolio/common/interfaces'; import { Activity, Filter } from '@ghostfolio/common/interfaces';
import { AssetProfileSplit, DataSource } from '@prisma/client'; import {
AssetProfileSplit,
DataSource,
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { ActivitiesService } from './activities.service'; import { ActivitiesService } from './activities.service';
@ -381,6 +386,43 @@ describe('ActivitiesService', () => {
return result.activities[0]; return result.activities[0];
} }
}); });
describe('getLatestActivity', () => {
it('filters by type and unit price and excludes draft activities', async () => {
const findFirst = jest.fn().mockResolvedValue(null);
const service = new ActivitiesService(
null,
null,
null,
null,
null,
null,
null,
null,
null,
{ order: { findFirst } } as unknown as PrismaService,
null,
null
);
await service.getLatestActivity({
dataSource: DataSource.YAHOO,
symbol: 'AAPL',
types: [ActivityType.BUY, ActivityType.SELL]
});
expect(findFirst).toHaveBeenCalledWith({
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
where: {
...WHERE_ACTIVITY_NOT_DRAFT,
SymbolProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' },
type: { in: [ActivityType.BUY, ActivityType.SELL] },
unitPrice: { gt: 0 }
}
});
});
});
}); });
function createActivity({ function createActivity({

14
apps/api/src/app/activities/activities.service.ts

@ -581,14 +581,16 @@ export class ActivitiesService {
public async getLatestActivity({ public async getLatestActivity({
dataSource, dataSource,
symbol symbol,
}: AssetProfileIdentifier) { types
}: AssetProfileIdentifier & { types: ActivityType[] }) {
return this.prismaService.order.findFirst({ return this.prismaService.order.findFirst({
orderBy: { orderBy: [{ date: 'desc' }, { createdAt: 'desc' }],
date: 'desc'
},
where: { where: {
SymbolProfile: { dataSource, symbol } ...WHERE_ACTIVITY_NOT_DRAFT,
SymbolProfile: { dataSource, symbol },
type: { in: types },
unitPrice: { gt: 0 }
} }
}); });
} }

16
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -5,7 +5,12 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData, Order } from '@prisma/client'; import {
DataSource,
MarketData,
Order,
Type as ActivityType
} from '@prisma/client';
import { addDays, subDays } from 'date-fns'; import { addDays, subDays } from 'date-fns';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
@ -220,8 +225,8 @@ describe('CurrentRateService', () => {
]); ]);
}); });
it('should fall back to the unit price of the latest activity without market data', async () => { it('should fall back to the unit price of the latest buy or sell activity without market data', async () => {
jest const getLatestActivity = jest
.spyOn(activitiesService, 'getLatestActivity') .spyOn(activitiesService, 'getLatestActivity')
.mockResolvedValue({ unitPrice: 1000 } as Order); .mockResolvedValue({ unitPrice: 1000 } as Order);
@ -232,6 +237,11 @@ describe('CurrentRateService', () => {
dateQuery dateQuery
}); });
expect(getLatestActivity).toHaveBeenCalledWith({
dataSource: DataSource.YAHOO,
symbol: 'AMZN',
types: [ActivityType.BUY, ActivityType.SELL]
});
expect(errors).toEqual(dataGatheringItems); expect(errors).toEqual(dataGatheringItems);
expect(values).toEqual([ expect(values).toEqual([
{ {

6
apps/api/src/app/portfolio/current-rate.service.ts

@ -13,6 +13,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Type as ActivityType } from '@prisma/client';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
import { isEmpty, uniqBy } from 'lodash-es'; import { isEmpty, uniqBy } from 'lodash-es';
@ -140,11 +141,12 @@ export class CurrentRateService {
let marketPrice = latestMarketData?.marketPrice; let marketPrice = latestMarketData?.marketPrice;
if (!marketPrice) { if (!marketPrice) {
// Fallback to unit price of latest activity // Fallback to unit price of latest buy or sell activity
const latestActivity = const latestActivity =
await this.activitiesService.getLatestActivity({ await this.activitiesService.getLatestActivity({
dataSource, dataSource,
symbol symbol,
types: [ActivityType.BUY, ActivityType.SELL]
}); });
marketPrice = latestActivity?.unitPrice ?? 0; marketPrice = latestActivity?.unitPrice ?? 0;

104
apps/api/src/helper/security-headers.helper.spec.ts

@ -0,0 +1,104 @@
import helmet from 'helmet';
import { IncomingMessage, ServerResponse } from 'node:http';
import { getHelmetOptions } from './security-headers.helper';
/**
* Gives the headers which the helmet middleware sets on a response with the
* options of the helper, and the function which it calls as next middleware.
*/
function getHeaders({
isSubscriptionEnabled
}: {
isSubscriptionEnabled: boolean;
}) {
const headers = new Map<string, string>();
const next = jest.fn();
const response = {
removeHeader: (name: string) => {
headers.delete(name.toLowerCase());
},
setHeader: (name: string, value: string) => {
headers.set(name.toLowerCase(), value);
}
};
helmet(getHelmetOptions({ isSubscriptionEnabled }))(
{} as IncomingMessage,
response as unknown as ServerResponse,
next
);
return { headers, next };
}
describe('getHelmetOptions', () => {
describe('without the subscription', () => {
let headers: Map<string, string>;
let next: jest.Mock;
beforeAll(() => {
({ headers, next } = getHeaders({ isSubscriptionEnabled: false }));
});
it('should call the next middleware without an error', () => {
expect(next).toHaveBeenCalledWith();
});
it('should set the Content-Security-Policy header', () => {
expect(headers.get('content-security-policy')).toContain(
"default-src 'self'"
);
});
it('should not upgrade insecure requests', () => {
expect(headers.get('content-security-policy')).not.toContain(
'upgrade-insecure-requests'
);
});
it('should not set the Strict-Transport-Security header', () => {
expect(headers.has('strict-transport-security')).toBe(false);
});
it('should not set the Cross-Origin-Opener-Policy header', () => {
expect(headers.has('cross-origin-opener-policy')).toBe(false);
});
it('should not allow resources of Stripe', () => {
expect(headers.get('content-security-policy')).not.toContain(
'https://js.stripe.com'
);
});
});
describe('with the subscription', () => {
let headers: Map<string, string>;
let next: jest.Mock;
beforeAll(() => {
({ headers, next } = getHeaders({ isSubscriptionEnabled: true }));
});
it('should call the next middleware without an error', () => {
expect(next).toHaveBeenCalledWith();
});
it('should upgrade insecure requests', () => {
expect(headers.get('content-security-policy')).toContain(
'upgrade-insecure-requests'
);
});
it('should set the Strict-Transport-Security header', () => {
expect(headers.has('strict-transport-security')).toBe(true);
});
it('should allow resources of Stripe', () => {
expect(headers.get('content-security-policy')).toContain(
'https://js.stripe.com'
);
});
});
});

37
apps/api/src/helper/security-headers.helper.ts

@ -0,0 +1,37 @@
import { HelmetOptions } from 'helmet';
export function getHelmetOptions({
isSubscriptionEnabled
}: {
isSubscriptionEnabled: boolean;
}): HelmetOptions {
if (isSubscriptionEnabled) {
return {
contentSecurityPolicy: {
directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
};
}
// The self-hosted setup can run via HTTP, hence the headers which need HTTPS
// are disabled (see https://github.com/ghostfolio/ghostfolio/issues/2102)
return {
contentSecurityPolicy: {
directives: {
scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles
upgradeInsecureRequests: null // Disable upgrade-insecure-requests directive (the browser changes each request to HTTPS, which gives a blank page via HTTP)
}
},
crossOriginOpenerPolicy: false, // Disable Cross-Origin-Opener-Policy header (the browser ignores it via HTTP)
strictTransportSecurity: false // Disable Strict-Transport-Security header (the reverse proxy sets it, if HTTPS is required)
};
}

30
apps/api/src/main.ts

@ -1,3 +1,4 @@
import { getHelmetOptions } from '@ghostfolio/api/helper/security-headers.helper';
import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware'; import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware';
import { createMcpAuthorizationMiddleware } from '@ghostfolio/api/middlewares/mcp-authorization.middleware'; import { createMcpAuthorizationMiddleware } from '@ghostfolio/api/middlewares/mcp-authorization.middleware';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -102,31 +103,30 @@ async function bootstrap() {
app.use(cookieParser()); app.use(cookieParser());
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { const configurationService = app.get(ConfigurationService);
const isSubscriptionEnabled =
configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true';
if (
isSubscriptionEnabled ||
configurationService.get('ENABLE_FEATURE_SECURITY_HEADERS')
) {
const helmetMiddleware = helmet(
getHelmetOptions({ isSubscriptionEnabled })
);
app.use((req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response, next: NextFunction) => {
if (req.path.startsWith(STORYBOOK_PATH)) { if (req.path.startsWith(STORYBOOK_PATH)) {
next(); next();
} else { } else {
helmet({ helmetMiddleware(req, res, next);
contentSecurityPolicy: {
directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})(req, res, next);
} }
}); });
} }
app.use(languageRedirectMiddleware); app.use(languageRedirectMiddleware);
const configurationService = app.get(ConfigurationService);
const trustProxy = configurationService.get('TRUST_PROXY'); const trustProxy = configurationService.get('TRUST_PROXY');
if (trustProxy) { if (trustProxy) {

3
apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts

@ -53,7 +53,8 @@ export class EmergencyFundCoverage extends Rule<Settings> {
const placeholders = { const placeholders = {
baseCurrency: ruleSettings.baseCurrency, baseCurrency: ruleSettings.baseCurrency,
emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString( emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString(
ruleSettings.locale ruleSettings.locale,
{ useGrouping: true }
) )
}; };

6
apps/api/src/models/rules/liquidity/buying-power.ts

@ -44,7 +44,8 @@ export class BuyingPower extends Rule<Settings> {
placeholders: { placeholders: {
baseCurrency: ruleSettings.baseCurrency, baseCurrency: ruleSettings.baseCurrency,
thresholdMin: ruleSettings.thresholdMin.toLocaleString( thresholdMin: ruleSettings.thresholdMin.toLocaleString(
ruleSettings.locale ruleSettings.locale,
{ useGrouping: true }
) )
} }
}), }),
@ -59,7 +60,8 @@ export class BuyingPower extends Rule<Settings> {
placeholders: { placeholders: {
baseCurrency: ruleSettings.baseCurrency, baseCurrency: ruleSettings.baseCurrency,
thresholdMin: ruleSettings.thresholdMin.toLocaleString( thresholdMin: ruleSettings.thresholdMin.toLocaleString(
ruleSettings.locale ruleSettings.locale,
{ useGrouping: true }
) )
} }
}), }),

3
apps/api/src/services/configuration/configuration.service.ts

@ -75,6 +75,9 @@ export class ConfigurationService {
ENABLE_FEATURE_MCP: bool({ default: false }), ENABLE_FEATURE_MCP: bool({ default: false }),
ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }), ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
// TODO: Change the default to true, when the security headers were
// available for some releases without a report of a defect
ENABLE_FEATURE_SECURITY_HEADERS: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),

1
apps/api/src/services/interfaces/environment.interface.ts

@ -26,6 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_MCP: boolean; ENABLE_FEATURE_MCP: boolean;
ENABLE_FEATURE_RATE_LIMITING: boolean; ENABLE_FEATURE_RATE_LIMITING: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SECURITY_HEADERS: boolean;
ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean; ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;

3
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -170,7 +170,8 @@ export class GfAdminOverviewComponent implements OnInit {
this.activitiesCount / this.userCount this.activitiesCount / this.userCount
).toLocaleString(this.user?.settings?.locale, { ).toLocaleString(this.user?.settings?.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
}); });
return `(${formattedActivitiesCountPerUser} ${$localize`per User`})`; return `(${formattedActivitiesCountPerUser} ${$localize`per User`})`;

3
libs/common/src/lib/chart-helper.ts

@ -123,7 +123,8 @@ export function getTooltipOptions<T extends ChartType>({
if (currency) { if (currency) {
label += `${yPoint.toLocaleString(locale, { label += `${yPoint.toLocaleString(locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
})} ${currency}`; })} ${currency}`;
} else if (unit) { } else if (unit) {
label += `${yPoint.toFixed(2)} ${unit}`; label += `${yPoint.toFixed(2)} ${unit}`;

6
libs/ui/src/lib/fire-calculator/fire-calculator.component.ts

@ -358,7 +358,8 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
return `Total: ${new Intl.NumberFormat(this.locale, { return `Total: ${new Intl.NumberFormat(this.locale, {
currency: this.currency ?? DEFAULT_CURRENCY, currency: this.currency ?? DEFAULT_CURRENCY,
currencyDisplay: 'code', currencyDisplay: 'code',
style: 'currency' style: 'currency',
useGrouping: true
}).format(totalAmount)}`; }).format(totalAmount)}`;
}, },
label: (context) => { label: (context) => {
@ -372,7 +373,8 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
label += new Intl.NumberFormat(this.locale, { label += new Intl.NumberFormat(this.locale, {
currency: this.currency ?? DEFAULT_CURRENCY, currency: this.currency ?? DEFAULT_CURRENCY,
currencyDisplay: 'code', currencyDisplay: 'code',
style: 'currency' style: 'currency',
useGrouping: true
}).format(context.parsed.y); }).format(context.parsed.y);
} }

3
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -258,7 +258,8 @@ export class GfLineChartComponent
if (typeof tickValue === 'number') { if (typeof tickValue === 'number') {
return tickValue.toLocaleString(this.locale, { return tickValue.toLocaleString(this.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
}); });
} }

3
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts

@ -496,7 +496,8 @@ export class GfPortfolioProportionChartComponent
`${name ?? symbol}`, `${name ?? symbol}`,
`${value.toLocaleString(this.locale, { `${value.toLocaleString(this.locale, {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
})} ${this.baseCurrency ?? ''} (${percentage.toFixed(2)}%)` })} ${this.baseCurrency ?? ''} (${percentage.toFixed(2)}%)`
]; ];
} }

15
libs/ui/src/lib/treemap-chart/treemap-chart.component.ts

@ -324,7 +324,8 @@ export class GfTreemapChartComponent
).toLocaleString(this.locale(), { ).toLocaleString(this.locale(), {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2, minimumFractionDigits: 2,
signDisplay: 'exceptZero' signDisplay: 'exceptZero',
useGrouping: true
})}%` })}%`
]; ];
}, },
@ -401,7 +402,8 @@ export class GfTreemapChartComponent
raw._data.allocationInPercentage * 100 raw._data.allocationInPercentage * 100
).toLocaleString(this.locale(), { ).toLocaleString(this.locale(), {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
})}%`; })}%`;
const name = getHoldingName(raw._data.assetProfile); const name = getHoldingName(raw._data.assetProfile);
@ -410,7 +412,8 @@ export class GfTreemapChartComponent
).toLocaleString(this.locale(), { ).toLocaleString(this.locale(), {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2, minimumFractionDigits: 2,
signDisplay: 'exceptZero' signDisplay: 'exceptZero',
useGrouping: true
})}%`; })}%`;
if (raw._data.valueInBaseCurrency !== null) { if (raw._data.valueInBaseCurrency !== null) {
@ -420,7 +423,8 @@ export class GfTreemapChartComponent
`${name} (${allocationInPercentage})`, `${name} (${allocationInPercentage})`,
`${value?.toLocaleString(this.locale(), { `${value?.toLocaleString(this.locale(), {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2 minimumFractionDigits: 2,
useGrouping: true
})} ${this.baseCurrency()}`, })} ${this.baseCurrency()}`,
'', '',
$localize`Change` + ' (' + $localize`Performance` + ')', $localize`Change` + ' (' + $localize`Performance` + ')',
@ -429,7 +433,8 @@ export class GfTreemapChartComponent
{ {
maximumFractionDigits: 2, maximumFractionDigits: 2,
minimumFractionDigits: 2, minimumFractionDigits: 2,
signDisplay: 'exceptZero' signDisplay: 'exceptZero',
useGrouping: true
} }
)} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})` )} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})`
]; ];

7
libs/ui/src/lib/value/value.component.ts

@ -83,7 +83,8 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
return { return {
maximumFractionDigits: digits, maximumFractionDigits: digits,
minimumFractionDigits: digits minimumFractionDigits: digits,
useGrouping: true
}; };
}); });
@ -150,7 +151,9 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy {
); );
} catch {} } catch {}
} else { } else {
this.formattedValue = this.value?.toLocaleString(this.locale); this.formattedValue = this.value?.toLocaleString(this.locale, {
useGrouping: true
});
} }
if (this.isAbsolute) { if (this.isAbsolute) {

Loading…
Cancel
Save