Browse Source

Merge branch 'main' into csv-importing-enhancement

pull/2496/head
Thomas Kaul 2 years ago
committed by GitHub
parent
commit
ed90ac361d
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 47
      CHANGELOG.md
  2. 12
      DEVELOPMENT.md
  3. 28
      apps/api/src/app/admin/admin.controller.ts
  4. 2
      apps/api/src/app/admin/admin.module.ts
  5. 35
      apps/api/src/app/admin/admin.service.ts
  6. 30
      apps/api/src/app/auth/web-auth.service.ts
  7. 9
      apps/api/src/app/import/import.service.ts
  8. 1
      apps/api/src/app/portfolio/portfolio.service.ts
  9. 2
      apps/api/src/app/subscription/subscription.controller.ts
  10. 8
      apps/api/src/app/user/user.service.ts
  11. 19
      apps/api/src/middlewares/html-template.middleware.ts
  12. 9
      apps/api/src/services/api/api.service.ts
  13. 1
      apps/api/src/services/configuration/configuration.service.ts
  14. 9
      apps/api/src/services/data-gathering/data-gathering.service.ts
  15. 10
      apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts
  16. 85
      apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts
  17. 67
      apps/api/src/services/i18n/i18n.service.ts
  18. 1
      apps/api/src/services/interfaces/environment.interface.ts
  19. 18
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  20. 5
      apps/client/src/app/app-routing.module.ts
  21. 1
      apps/client/src/app/app.component.html
  22. 2
      apps/client/src/app/app.component.ts
  23. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.scss
  24. 50
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  25. 11
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  26. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts
  27. 6
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
  28. 1
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss
  29. 10
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  30. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  31. 3
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.scss
  32. 56
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  33. 24
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html
  34. 4
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.module.ts
  35. 1
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  36. 30
      apps/client/src/app/components/admin-users/admin-users.component.ts
  37. 208
      apps/client/src/app/components/admin-users/admin-users.html
  38. 4
      apps/client/src/app/components/admin-users/admin-users.module.ts
  39. 1
      apps/client/src/app/components/dialog-footer/dialog-footer.component.html
  40. 4
      apps/client/src/app/components/dialog-footer/dialog-footer.component.scss
  41. 3
      apps/client/src/app/components/header/header.component.html
  42. 2
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  43. 8
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html
  44. 2
      apps/client/src/app/components/user-account-access/user-account-access.module.ts
  45. 30
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  46. 2
      apps/client/src/app/components/user-account-membership/user-account-membership.module.ts
  47. 24
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  48. 22
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  49. 4
      apps/client/src/app/components/user-account-settings/user-account-settings.module.ts
  50. 2
      apps/client/src/app/pages/about/oss-friends/oss-friends-page.html
  51. 2
      apps/client/src/app/pages/about/overview/about-overview-page.component.ts
  52. 3
      apps/client/src/app/pages/about/overview/about-overview-page.html
  53. 10
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts
  54. 11
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  55. 6
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts
  56. 19
      apps/client/src/app/pages/i18n/i18n-page-routing.module.ts
  57. 21
      apps/client/src/app/pages/i18n/i18n-page.component.ts
  58. 10
      apps/client/src/app/pages/i18n/i18n-page.html
  59. 12
      apps/client/src/app/pages/i18n/i18n-page.module.ts
  60. 3
      apps/client/src/app/pages/i18n/i18n-page.scss
  61. 10
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  62. 4
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  63. 4
      apps/client/src/app/services/web-authn.service.ts
  64. 42
      apps/client/src/assets/oss-friends.json
  65. 3
      apps/client/src/environments/environment.prod.ts
  66. 3
      apps/client/src/environments/environment.ts
  67. 470
      apps/client/src/locales/messages.de.xlf
  68. 472
      apps/client/src/locales/messages.es.xlf
  69. 474
      apps/client/src/locales/messages.fr.xlf
  70. 474
      apps/client/src/locales/messages.it.xlf
  71. 474
      apps/client/src/locales/messages.nl.xlf
  72. 474
      apps/client/src/locales/messages.pt.xlf
  73. 474
      apps/client/src/locales/messages.tr.xlf
  74. 458
      apps/client/src/locales/messages.xlf
  75. 15
      apps/client/src/styles.scss
  76. 9
      libs/common/src/lib/helper.ts
  77. 2
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  78. 4
      libs/common/src/lib/interfaces/currency.interface.ts
  79. 6
      libs/common/src/lib/interfaces/position.interface.ts
  80. 32
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  81. 20
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html
  82. 3
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.module.ts
  83. 83
      libs/ui/src/lib/assistant/assistant.component.ts
  84. 26
      libs/ui/src/lib/assistant/assistant.html
  85. 11
      libs/ui/src/lib/assistant/interfaces/interfaces.ts
  86. 21
      libs/ui/src/lib/currency-selector/currency-selector.component.html
  87. 3
      libs/ui/src/lib/currency-selector/currency-selector.component.scss
  88. 167
      libs/ui/src/lib/currency-selector/currency-selector.component.ts
  89. 23
      libs/ui/src/lib/currency-selector/currency-selector.module.ts
  90. 2
      libs/ui/src/lib/logo/logo.component.html
  91. 8
      libs/ui/src/lib/logo/logo.component.scss
  92. 1
      libs/ui/src/lib/membership-card/index.ts
  93. 29
      libs/ui/src/lib/membership-card/membership-card.component.html
  94. 66
      libs/ui/src/lib/membership-card/membership-card.component.scss
  95. 14
      libs/ui/src/lib/membership-card/membership-card.component.ts
  96. 14
      libs/ui/src/lib/membership-card/membership-card.module.ts
  97. 0
      libs/ui/src/lib/shared/abstract-mat-form-field.ts
  98. 5
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  99. 10
      package.json
  100. 5
      prisma/migrations/20231021094346_added_figi_figi_composite_and_figi_share_class_to_symbol_profile/migration.sql

47
CHANGELOG.md

@ -6,24 +6,67 @@ 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).
## Unreleased
- Improved the style and wording of the position detail dialog
- Improved the validation in the activities import (expects positive values for `quantity` and `unitPrice`)
- Changed the currency selector in the create or update account dialog to `@angular/material/autocomplete`
- Upgraded `yahoo-finance2` from version `2.8.0` to `2.8.1`
## 2.14.0 - 2023-10-21
### Added
- Added the _OpenFIGI_ data enhancer for _Financial Instrument Global Identifier_ (FIGI)
- Added `figi`, `figiComposite` and `figiShareClass` to the asset profile model
### Changed
- Improved the validation in the activities import (expects positive values for `quantity` and `unitPrice`)
- Moved the fees on account level feature from experimental to general availability
- Moved the interest on account level feature from experimental to general availability
- Moved the search for a holding from experimental to general availability
- Improved the error message in the activities import for `csv` files
- Removed the application version from the client
- Allowed to edit today’s historical market data in the asset profile details dialog of the admin control panel
## Unreleased
### Fixed
- Fixed the style of the active page in the header navigation
- Trimmed text in `i18n` service to query `messages.*.xlf` files on the server
## 2.13.0 - 2023-10-20
### Added
- Added a chart to the account detail dialog
- Added an `i18n` service to query `messages.*.xlf` files on the server
### Changed
- Changed the users table in the admin control panel to an `@angular/material` data table
- Improved the styling of the membership status
### Fixed
- Fixed an issue where holdings were requested twice from the server
## 2.12.0 - 2023-10-17
### Added
- Added the endpoint `GET api/v1/account/:id/balances` which provides historical cash balances
- Added support to search for an asset profile by `isin`, `name` and `symbol` as an administrator (experimental)
- Added support for creating asset profiles with `MANUAL` data source
### Changed
- Changed the checkboxes to slide toggles in the user settings of the user account page
- Extended the `copy-assets` `Nx` target to copy the locales to the server’s assets
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `5.2.1` to `8.3`
### Fixed
- Displayed the transfer cash balance button based on a permission
- Fixed the biometric authentication
- Fixed the query to get asset profiles that match both the `dataSource` and `symbol` values
## 2.11.0 - 2023-10-14

12
DEVELOPMENT.md

@ -1,5 +1,17 @@
# Ghostfolio Development Guide
## Experimental Features
New functionality can be enabled using a feature flag switch from the user settings.
### Backend
Remove permission in `UserService` using `without()`
### Frontend
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
## Git
### Rebase

28
apps/api/src/app/admin/admin.controller.ts

@ -1,9 +1,9 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
@ -12,8 +12,7 @@ import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
EnhancedSymbolProfile,
Filter
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type {
@ -50,6 +49,7 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController {
public constructor(
private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
@ -255,6 +255,7 @@ export class AdminController {
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -272,16 +273,10 @@ export class AdminController {
);
}
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const filters: Filter[] = [
...assetSubClasses.map((assetSubClass) => {
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterBySearchQuery
});
return this.adminService.getMarketData({
filters,
@ -403,8 +398,11 @@ export class AdminController {
StatusCodes.FORBIDDEN
);
}
return this.adminService.addAssetProfile({ dataSource, symbol });
return this.adminService.addAssetProfile({
dataSource,
symbol,
currency: this.request.user.Settings.settings.baseCurrency
});
}
@Delete('profile-data/:dataSource/:symbol')

2
apps/api/src/app/admin/admin.module.ts

@ -1,4 +1,5 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ApiModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

35
apps/api/src/app/admin/admin.service.ts

@ -41,10 +41,19 @@ export class AdminService {
) {}
public async addAssetProfile({
currency,
dataSource,
symbol
}: UniqueAsset): Promise<SymbolProfile | never> {
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
try {
if (dataSource === 'MANUAL') {
return this.symbolProfileService.add({
currency,
dataSource,
symbol
});
}
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
]);
@ -131,10 +140,14 @@ export class AdminService {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
}
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
(filter) => {
return filter.type;
({ type }) => {
return type;
}
);
@ -147,6 +160,14 @@ export class AdminService {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
}
if (searchQuery) {
where.OR = [
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
@ -173,7 +194,9 @@ export class AdminService {
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
name: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
@ -194,7 +217,9 @@ export class AdminService {
assetSubClass,
comment,
countries,
currency,
dataSource,
name,
Order,
sectors,
symbol
@ -213,8 +238,10 @@ export class AdminService {
assetClass,
assetSubClass,
comment,
currency,
countriesCount,
dataSource,
name,
symbol,
marketDataItemCount,
sectorsCount,
@ -341,6 +368,8 @@ export class AdminService {
symbol,
assetClass: 'CASH',
countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
name: symbol,
sectorsCount: 0
};
});

30
apps/api/src/app/auth/web-auth.service.ts

@ -64,7 +64,7 @@ export class WebAuthService {
}
};
const options = generateRegistrationOptions(opts);
const options = await generateRegistrationOptions(opts);
await this.userService.updateUser({
data: {
@ -88,10 +88,16 @@ export class WebAuthService {
let verification: VerifiedRegistrationResponse;
try {
const opts: VerifyRegistrationResponseOpts = {
credential,
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
expectedRPID: this.rpID,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
};
verification = await verifyRegistrationResponse(opts);
} catch (error) {
@ -117,8 +123,8 @@ export class WebAuthService {
*/
existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialPublicKey,
credentialId: credentialID,
credentialId: Buffer.from(credentialID),
credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } }
});
}
@ -152,7 +158,7 @@ export class WebAuthService {
userVerification: 'preferred'
};
const options = generateAuthenticationOptions(opts);
const options = await generateAuthenticationOptions(opts);
await this.userService.updateUser({
data: {
@ -181,7 +187,6 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse;
try {
const opts: VerifyAuthenticationResponseOpts = {
credential,
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
@ -189,9 +194,16 @@ export class WebAuthService {
},
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
expectedRPID: this.rpID,
response: {
clientExtensionResults: credential.clientExtensionResults,
id: credential.id,
rawId: credential.rawId,
response: credential.response,
type: 'public-key'
}
};
verification = verifyAuthenticationResponse(opts);
verification = await verifyAuthenticationResponse(opts);
} catch (error) {
Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message });

9
apps/api/src/app/import/import.service.ts

@ -280,6 +280,9 @@ export class ImportService {
createdAt,
currency,
dataSource,
figi,
figiComposite,
figiShareClass,
id,
isin,
name,
@ -350,6 +353,9 @@ export class ImportService {
createdAt,
currency,
dataSource,
figi,
figiComposite,
figiShareClass,
id,
isin,
name,
@ -509,6 +515,9 @@ export class ImportService {
comment: null,
countries: null,
createdAt: undefined,
figi: null,
figiComposite: null,
figiShareClass: null,
id: undefined,
isin: null,
name: null,

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

@ -1088,6 +1088,7 @@ export class PortfolioService {
return {
...position,
assetClass: symbolProfileMap[position.symbol].assetClass,
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:

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

@ -104,7 +104,7 @@ export class SubscriptionController {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/account`
)}/${DEFAULT_LANGUAGE_CODE}/account/membership`
);
}

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

@ -164,10 +164,10 @@ export class UserService {
let currentPermissions = getPermissions(user.role);
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
currentPermissions = without(
currentPermissions,
permissions.accessAssistant
);
// currentPermissions = without(
// currentPermissions,
// permissions.xyz
// );
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {

19
apps/api/src/middlewares/html-template.middleware.ts

@ -2,6 +2,7 @@ import * as fs from 'fs';
import { join } from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL,
@ -11,20 +12,11 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
const descriptions = {
de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.',
es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
tr: 'Ghostfolio, hisse senetleri, ETF’ler veya kripto para birimleri gibi varlıklarınızı birden fazla platformda takip etmenizi sağlayan bir kişisel finans panosudur.'
};
const title = 'Ghostfolio – Open Source Wealth Management Software';
const titleShort = 'Ghostfolio';
const i18nService = new I18nService();
let indexHtmlMap: { [languageCode: string]: string } = {};
try {
@ -130,7 +122,10 @@ export const HtmlTemplateMiddleware = async (
languageCode,
path,
rootUrl,
description: descriptions[languageCode],
description: i18nService.getTranslation({
languageCode,
id: 'metaDescription'
}),
featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title

9
apps/api/src/services/api/api.service.ts

@ -8,16 +8,19 @@ export class ApiService {
public buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByAssetSubClasses,
filterBySearchQuery,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterBySearchQuery?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? [];
@ -34,6 +37,12 @@ export class ApiService {
type: 'ASSET_CLASS'
};
}),
...assetSubClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_SUB_CLASS'
};
}),
{
id: searchQuery,
type: 'SEARCH_QUERY'

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

@ -38,6 +38,7 @@ export class ConfigurationService {
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
OPEN_FIGI_API_KEY: str({ default: '' }),
PORT: port({ default: 3333 }),
RAPID_API_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }),

9
apps/api/src/services/data-gathering/data-gathering.service.ts

@ -164,6 +164,9 @@ export class DataGatheringService {
countries,
currency,
dataSource,
figi,
figiComposite,
figiShareClass,
isin,
name,
sectors,
@ -178,6 +181,9 @@ export class DataGatheringService {
countries,
currency,
dataSource,
figi,
figiComposite,
figiShareClass,
isin,
name,
sectors,
@ -189,6 +195,9 @@ export class DataGatheringService {
assetSubClass,
countries,
currency,
figi,
figiComposite,
figiShareClass,
isin,
name,
sectors,

10
apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts

@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service';
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { Module } from '@nestjs/common';
@ -9,6 +10,7 @@ import { DataEnhancerService } from './data-enhancer.service';
@Module({
exports: [
DataEnhancerService,
OpenFigiDataEnhancerService,
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService,
'DataEnhancers'
@ -16,15 +18,21 @@ import { DataEnhancerService } from './data-enhancer.service';
imports: [ConfigurationModule, CryptocurrencyModule],
providers: [
DataEnhancerService,
OpenFigiDataEnhancerService,
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService,
{
inject: [
OpenFigiDataEnhancerService,
TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService
],
provide: 'DataEnhancers',
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance]
useFactory: (openfigi, trackinsight, yahooFinance) => [
openfigi,
trackinsight,
yahooFinance
]
}
]
})

85
apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts

@ -0,0 +1,85 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { parseSymbol } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import got, { Headers } from 'got';
@Injectable()
export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://api.openfigi.com';
public constructor(
private readonly configurationService: ConfigurationService
) {}
public async enhance({
response,
symbol
}: {
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
if (
!(
response.assetClass === 'EQUITY' &&
(response.assetSubClass === 'ETF' || response.assetSubClass === 'STOCK')
)
) {
return response;
}
const headers: Headers = {};
const { exchange, ticker } = parseSymbol({
symbol,
dataSource: response.dataSource
});
if (this.configurationService.get('OPEN_FIGI_API_KEY')) {
headers['X-OPENFIGI-APIKEY'] =
this.configurationService.get('OPEN_FIGI_API_KEY');
}
let abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, DEFAULT_REQUEST_TIMEOUT);
const mappings = await got
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
headers,
json: [{ exchCode: exchange, idType: 'TICKER', idValue: ticker }],
// @ts-ignore
signal: abortController.signal
})
.json<any[]>();
if (mappings?.length === 1 && mappings[0].data?.length === 1) {
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];
if (figi) {
response.figi = figi;
}
if (compositeFIGI) {
response.figiComposite = compositeFIGI;
}
if (shareClassFIGI) {
response.figiShareClass = shareClassFIGI;
}
}
return response;
}
public getName() {
return 'OPENFIGI';
}
public getTestSymbol() {
return undefined;
}
}

67
apps/api/src/services/i18n/i18n.service.ts

@ -0,0 +1,67 @@
import { readFileSync, readdirSync } from 'fs';
import { join } from 'path';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
export class I18nService {
private localesPath = join(__dirname, 'assets', 'locales');
private translations: { [locale: string]: cheerio.CheerioAPI } = {};
public constructor() {
this.loadFiles();
}
public getTranslation({
id,
languageCode
}: {
id: string;
languageCode: string;
}): string {
const $ = this.translations[languageCode];
if (!$) {
Logger.warn(`Translation not found for locale '${languageCode}'`);
}
const translatedText = $(
`trans-unit[id="${id}"] > ${
languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target'
}`
).text();
if (!translatedText) {
Logger.warn(
`Translation not found for id '${id}' in locale '${languageCode}'`
);
}
return translatedText.trim();
}
private loadFiles() {
try {
const files = readdirSync(this.localesPath, 'utf-8');
for (const file of files) {
const xmlData = readFileSync(join(this.localesPath, file), 'utf8');
this.translations[this.parseLanguageCode(file)] =
this.parseXml(xmlData);
}
} catch (error) {
Logger.error(error, 'I18nService');
}
}
private parseLanguageCode(aFileName: string) {
const match = aFileName.match(/\.([a-zA-Z]+)\.xlf$/);
return match ? match[1] : DEFAULT_LANGUAGE_CODE;
}
private parseXml(xmlData: string): cheerio.CheerioAPI {
return cheerio.load(xmlData, { xmlMode: true });
}
}

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

@ -26,6 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number;
OPEN_FIGI_API_KEY: string;
PORT: number;
RAPID_API_API_KEY: string;
REDIS_HOST: string;

18
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -52,21 +52,13 @@ export class SymbolProfileService {
SymbolProfileOverrides: true
},
where: {
AND: [
{
dataSource: {
in: aUniqueAssets.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: aUniqueAssets.map(({ symbol }) => {
return symbol;
OR: aUniqueAssets.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
})
}
}
]
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
}

5
apps/client/src/app/app-routing.module.ts

@ -73,6 +73,11 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
},
{
path: 'i18n',
loadChildren: () =>
import('./pages/i18n/i18n-page.module').then((m) => m.I18nPageModule)
},
{
path: paths.markets,
loadChildren: () =>

1
apps/client/src/app/app.component.html

@ -165,7 +165,6 @@
<div class="row text-center">
<div class="col">
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
{{ version }}
</div>
</div>

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

@ -17,7 +17,6 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment';
import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@ -60,7 +59,6 @@ export class AppComponent implements OnDestroy, OnInit {
public routerLinkResources = ['/' + $localize`resources`];
public showFooter = false;
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();

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

@ -3,5 +3,9 @@
.mat-mdc-dialog-content {
max-height: unset;
.chart-container {
aspect-ratio: 16 / 9;
}
}
}

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

@ -8,11 +8,11 @@ import {
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { HistoricalDataItem, User } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import Big from 'big.js';
import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash';
@ -32,6 +32,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public balance: number;
public currency: string;
public equity: number;
public hasImpersonationId: boolean;
public historicalDataItems: HistoricalDataItem[];
public isLoadingChart: boolean;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
@ -46,6 +49,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {
this.userService.stateChanged
@ -59,7 +63,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
});
}
public ngOnInit(): void {
public ngOnInit() {
this.isLoadingChart = true;
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
@ -101,9 +107,45 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPortfolioPerformance({
filters: [
{
id: this.data.accountId,
type: 'ACCOUNT'
}
],
range: 'max'
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {
this.historicalDataItems = chart.map(
({ date, value, valueInPercentage }) => {
return {
date,
value:
this.hasImpersonationId || this.user.settings.isRestrictedView
? valueInPercentage
: value
};
}
);
this.isLoadingChart = false;
this.changeDetectorRef.markForCheck();
});
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
}
public onClose(): void {
public onClose() {
this.dialogRef.close();
}

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

@ -20,6 +20,17 @@
</div>
</div>
<div class="chart-container mb-3">
<gf-investment-chart
class="h-100"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[isLoading]="isLoadingChart"
[locale]="user?.settings?.locale"
></gf-investment-chart>
</div>
<div class="row">
<div class="col-6 mb-3">
<gf-value

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

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -17,6 +18,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
GfActivitiesTableModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfInvestmentChartModule,
GfValueModule,
MatButtonModule,
MatDialogModule,

6
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html

@ -9,7 +9,11 @@
[showYAxis]="true"
[symbol]="symbol"
></gf-line-chart>
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
<div
*ngFor="let itemByMonth of marketDataByMonth | keyvalue"
class="d-flex"
[hidden]="!marketData.length > 0"
>
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1">
<div

1
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss

@ -28,7 +28,6 @@
&.today {
background-color: rgba(var(--palette-accent-500), 1);
cursor: default;
}
}
}

10
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts

@ -83,10 +83,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale);
this.historicalDataItems = this.marketData.map((marketDataItem) => {
this.historicalDataItems = this.marketData.map(({ date, marketPrice }) => {
return {
date: format(marketDataItem.date, DATE_FORMAT),
value: marketDataItem.marketPrice
date: format(date, DATE_FORMAT),
value: marketPrice
};
});
@ -157,10 +157,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) {
return;
}
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: <MarketDataDetailDialogParams>{
date,

12
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -178,10 +178,20 @@ export class AdminMarketDataComponent
}
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
const confirmation = confirm(
$localize`Do you really want to delete this asset profile?`
);
if (confirmation) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}
public onGather7Days() {

3
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

56
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts

@ -1,15 +1,15 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormControl,
FormGroup,
ValidationErrors,
Validators
} from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
@ -19,35 +19,75 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-asset-profile-dialog',
styleUrls: ['./create-asset-profile-dialog.component.scss'],
templateUrl: 'create-asset-profile-dialog.html'
})
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
public createAssetProfileForm: FormGroup;
public mode: 'auto' | 'manual';
public constructor(
public readonly adminService: AdminService,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
public readonly formBuilder: FormBuilder
) {}
public ngOnInit() {
this.createAssetProfileForm = this.formBuilder.group({
this.createAssetProfileForm = this.formBuilder.group(
{
addSymbol: new FormControl(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required])
});
},
{
validators: this.atLeastOneValid
}
);
this.mode = 'auto';
}
public onCancel() {
this.dialogRef.close();
}
public onRadioChange(mode: 'auto' | 'manual') {
this.mode = mode;
}
public onSubmit() {
this.dialogRef.close({
this.mode === 'auto'
? this.dialogRef.close({
dataSource:
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
this.createAssetProfileForm.controls['searchSymbol'].value
.dataSource,
symbol:
this.createAssetProfileForm.controls['searchSymbol'].value.symbol
})
: this.dialogRef.close({
dataSource: 'MANUAL',
symbol: this.createAssetProfileForm.controls['addSymbol'].value
});
}
public ngOnDestroy() {}
private atLeastOneValid(control: AbstractControl): ValidationErrors {
const addSymbolControl = control.get('addSymbol');
const searchSymbolControl = control.get('searchSymbol');
if (addSymbolControl.valid && searchSymbolControl.valid) {
return { atLeastOneValid: true };
}
if (
addSymbolControl.valid ||
!addSymbolControl ||
searchSymbolControl.valid ||
!searchSymbolControl
) {
return { atLeastOneValid: false };
}
return { atLeastOneValid: true };
}
}

24
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html

@ -6,6 +6,21 @@
>
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<div class="mb-3">
<mat-radio-group
color="primary"
[value]="mode"
(change)="onRadioChange($event.value)"
>
<mat-radio-button name="auto" value="auto"></mat-radio-button>
<label class="m-0" for="auto" i18n>Search</label>
<mat-radio-button class="ml-3" name="manual" value="manual">
</mat-radio-button>
<label class="m-0" for="manual" i18n>Add Manually</label>
</mat-radio-group>
</div>
<div *ngIf="mode === 'auto'">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete
@ -14,13 +29,20 @@
/>
</mat-form-field>
</div>
<div *ngIf="mode === 'manual'">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Symbol</mat-label>
<input formControlName="addSymbol" matInput />
</mat-form-field>
</div>
</div>
<div class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!createAssetProfileForm.valid"
[disabled]="createAssetProfileForm.hasError('atLeastOneValid')"
>
<ng-container i18n>Save</ng-container>
</button>

4
apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.module.ts

@ -4,6 +4,8 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatRadioModule } from '@angular/material/radio';
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
@ -17,6 +19,8 @@ import { CreateAssetProfileDialog } from './create-asset-profile-dialog.componen
MatDialogModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatRadioModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

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

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { environment } from '@ghostfolio/client/../environments/environment';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';

30
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -20,13 +21,15 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public dataSource: MatTableDataSource<AdminData['users'][0]> =
new MatTableDataSource();
public defaultDateFormat: string;
public displayedColumns: string[] = [];
public getEmojiFlag = getEmojiFlag;
public hasPermissionForSubscription: boolean;
public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem;
public user: User;
public users: AdminData['users'];
private unsubscribeSubject = new Subject<void>();
@ -44,6 +47,29 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
permissions.enableSubscription
);
if (this.hasPermissionForSubscription) {
this.displayedColumns = [
'index',
'user',
'country',
'registration',
'accounts',
'activities',
'engagementPerDay',
'lastRequest',
'actions'
];
} else {
this.displayedColumns = [
'index',
'user',
'registration',
'accounts',
'activities',
'actions'
];
}
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -118,7 +144,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => {
this.users = users;
this.dataSource = new MatTableDataSource(users);
this.changeDetectorRef.markForCheck();
});

208
apps/client/src/app/components/admin-users/admin-users.html

@ -2,107 +2,193 @@
<div class="row">
<div class="col">
<div class="users">
<table class="gf-table">
<thead>
<tr class="mat-mdc-header-row">
<th class="mat-mdc-header-cell px-1 py-2 text-right">#</th>
<th class="mat-mdc-header-cell px-1 py-2" i18n>User</th>
<table class="gf-table" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="index">
<th
*ngIf="hasPermissionForSubscription"
class="mat-mdc-header-cell px-1 py-2"
>
<ng-container i18n>Country</ng-container>
</th>
<th class="mat-mdc-header-cell px-1 py-2">
<ng-container i18n>Registration</ng-container>
</th>
<th class="mat-mdc-header-cell px-1 py-2 text-right">
<ng-container i18n>Accounts</ng-container>
</th>
<th class="mat-mdc-header-cell px-1 py-2 text-right">
<ng-container i18n>Activities</ng-container>
</th>
<th
*ngIf="hasPermissionForSubscription"
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
>
<ng-container i18n>Engagement per Day</ng-container>
#
</th>
<td
*matCellDef="let element; let i=index"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
{{ i + 1 }}
</td>
</ng-container>
<ng-container matColumnDef="user">
<th
*ngIf="hasPermissionForSubscription"
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
i18n
mat-header-cell
>
Last Request
User
</th>
<th class="mat-mdc-header-cell px-1 py-2"></th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let userItem of users; let i = index"
class="mat-mdc-row"
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
mat-cell
>
<td class="mat-mdc-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-mdc-cell px-1 py-2">
<div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block text-monospace"
>{{ userItem.id }}</span
>{{ element.id }}</span
>
<span class="d-inline-block d-sm-none text-monospace"
>{{ (userItem.id | slice:0:5) + '...' }}</span
>{{ (element.id | slice:0:5) + '...' }}</span
>
<gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'"
*ngIf="element?.subscription?.type === 'Premium'"
class="ml-1"
[enableLink]="false"
[title]="'Expires ' + formatDistanceToNow(userItem.subscription.expiresAt) + ' (' + (userItem.subscription.expiresAt | date: defaultDateFormat) + ')'"
[title]="'Expires ' + formatDistanceToNow(element.subscription.expiresAt) + ' (' + (element.subscription.expiresAt | date: defaultDateFormat) + ')'"
></gf-premium-indicator>
</div>
</td>
<td
</ng-container>
<ng-container
*ngIf="hasPermissionForSubscription"
matColumnDef="country"
>
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
mat-header-cell
>
<ng-container i18n>Country</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
mat-cell
>
<span class="h5" [title]="userItem.country"
>{{ getEmojiFlag(userItem.country) }}</span
<span class="h5" [title]="element.country"
>{{ getEmojiFlag(element.country) }}</span
>
</td>
<td class="mat-mdc-cell px-1 py-2">
{{ formatDistanceToNow(userItem.createdAt) }}
</ng-container>
<ng-container matColumnDef="registration">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
mat-header-cell
>
<ng-container i18n>Registration</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
mat-cell
>
{{ formatDistanceToNow(element.createdAt) }}
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
</ng-container>
<ng-container matColumnDef="accounts">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
>
<ng-container i18n>Accounts</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="userItem.accountCount"
[value]="element.accountCount"
></gf-value>
</td>
<td class="mat-mdc-cell px-1 py-2 text-right">
</ng-container>
<ng-container matColumnDef="activities">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
>
<ng-container i18n>Activities</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[value]="userItem.transactionCount"
[value]="element.transactionCount"
></gf-value>
</td>
<td
</ng-container>
<ng-container
*ngIf="hasPermissionForSubscription"
matColumnDef="engagementPerDay"
>
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
>
<ng-container i18n>Engagement per Day</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userItem.engagement"
[value]="element.engagement"
></gf-value>
</td>
<td
</ng-container>
<ng-container
*ngIf="hasPermissionForSubscription"
matColumnDef="lastRequest"
>
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
i18n
mat-header-cell
>
Last Request
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
mat-cell
>
{{ formatDistanceToNow(userItem.lastActivity) }}
{{ formatDistanceToNow(element.lastActivity) }}
</td>
<td class="mat-mdc-cell px-1 py-2">
</ng-container>
<ng-container matColumnDef="actions">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2"
mat-header-cell
></th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2"
mat-cell
>
<button
class="mx-1 no-min-width px-2"
mat-button
@ -115,23 +201,33 @@
<button
*ngIf="hasPermissionToImpersonateAllUsers"
mat-menu-item
(click)="onImpersonateUser(userItem.id)"
(click)="onImpersonateUser(element.id)"
>
<ion-icon class="mr-2" name="contract-outline"></ion-icon>
<span i18n>Impersonate User</span>
</button>
<button
mat-menu-item
[disabled]="userItem.id === user?.id"
(click)="onDeleteUser(userItem.id)"
[disabled]="element.id === user?.id"
(click)="onDeleteUser(element.id)"
>
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete User</span>
</button>
</mat-menu>
</td>
</tr>
</tbody>
</ng-container>
<tr
*matHeaderRowDef="displayedColumns"
class="mat-mdc-header-row"
mat-header-row
></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
class="mat-mdc-row"
mat-row
></tr>
</table>
</div>
</div>

4
apps/client/src/app/components/admin-users/admin-users.module.ts

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -15,7 +16,8 @@ import { AdminUsersComponent } from './admin-users.component';
GfPremiumIndicatorModule,
GfValueModule,
MatButtonModule,
MatMenuModule
MatMenuModule,
MatTableModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

1
apps/client/src/app/components/dialog-footer/dialog-footer.component.html

@ -1,6 +1,5 @@
<button
*ngIf="deviceType === 'mobile'"
class="mt-2"
mat-button
(click)="onClickCloseButton()"
>

4
apps/client/src/app/components/dialog-footer/dialog-footer.component.scss

@ -1,7 +1,9 @@
:host {
display: flex;
flex: 0 0 auto;
margin-bottom: 0;
min-height: 0;
@media (min-width: 576px) {
padding: 0 !important;
}
}

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

@ -131,6 +131,9 @@
<gf-assistant
#assistant
[deviceType]="deviceType"
[hasPermissionToAccessAdminControl]="
hasPermissionToAccessAdminControl
"
(closed)="closeAssistant()"
/>
</mat-menu>

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

@ -81,8 +81,6 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.update();
}
public onChangeDateRange(dateRange: DateRange) {

8
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html

@ -155,16 +155,18 @@
[isDate]="true"
[locale]="data.locale"
[value]="firstBuyDate"
>First Buy Date</gf-value
>First Activity</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="transactionCount"
>Transactions</gf-value
><ng-container *ngIf="transactionCount === 1">Activity</ng-container
><ng-container *ngIf="transactionCount !== 1"
>Activities</ng-container
></gf-value
>
</div>
<div class="col-6 mb-3">

2
apps/client/src/app/components/user-account-access/user-account-access.module.ts

@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
@ -7,7 +8,6 @@ import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
import { UserAccountAccessComponent } from './user-account-access.component';
import { MatButtonModule } from '@angular/material/button';
@NgModule({
declarations: [UserAccountAccessComponent],

30
apps/client/src/app/components/user-account-membership/user-account-membership.html

@ -2,22 +2,15 @@
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1>
<div class="row">
<div class="col">
<div class="d-flex">
<div class="mx-auto">
<div class="align-items-center d-flex mb-1">
<a [routerLink]="routerLinkPricing"
>{{ user?.subscription?.type }}</a
<div class="align-items-center d-flex flex-column">
<gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[name]="user?.subscription?.type"
></gf-membership-card>
<div
*ngIf="user?.subscription?.type === 'Basic'"
class="d-flex flex-column mt-5"
>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Premium'"
class="ml-1"
></gf-premium-indicator>
</div>
<div *ngIf="user?.subscription?.type === 'Premium'">
<ng-container i18n>Valid until</ng-container> {{
user?.subscription?.expiresAt | date: defaultDateFormat }}
</div>
<div *ngIf="user?.subscription?.type === 'Basic'">
<ng-container
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
>
@ -29,7 +22,7 @@
>Renew</ng-container
>
</button>
<div *ngIf="price" class="mt-1">
<div *ngIf="price" class="mt-1 text-center">
<ng-container *ngIf="coupon"
><del class="text-muted"
>{{ baseCurrency }}&nbsp;{{ price }}</del
@ -41,9 +34,10 @@
>&nbsp;<span i18n>per year</span>
</div>
</ng-container>
<div class="align-items-center d-flex justfiy-content-center mt-4">
<a
*ngIf="!user?.subscription?.expiresAt"
class="mr-2 my-2"
class="mx-1"
mat-stroked-button
[href]="trySubscriptionMail"
><span i18n>Try Premium</span>
@ -54,7 +48,7 @@
></a>
<a
*ngIf="hasPermissionToUpdateUserSettings"
class="mr-2 my-2"
class="mx-1"
i18n
mat-stroked-button
[routerLink]=""

2
apps/client/src/app/components/user-account-membership/user-account-membership.module.ts

@ -3,6 +3,7 @@ import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { GfMembershipCardModule } from '@ghostfolio/ui/membership-card';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -13,6 +14,7 @@ import { UserAccountMembershipComponent } from './user-account-membership.compon
exports: [UserAccountMembershipComponent],
imports: [
CommonModule,
GfMembershipCardModule,
GfPremiumIndicatorModule,
GfValueModule,
MatButtonModule,

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

@ -3,10 +3,9 @@ import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
OnInit
} from '@angular/core';
import { MatCheckbox, MatCheckboxChange } from '@angular/material/checkbox';
import { MatSlideToggleChange } from '@angular/material/slide-toggle';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
STAY_SIGNED_IN,
@ -29,14 +28,12 @@ import { catchError, takeUntil } from 'rxjs/operators';
templateUrl: './user-account-settings.html'
})
export class UserAccountSettingsComponent implements OnDestroy, OnInit {
@ViewChild('toggleSignInWithFingerprintEnabledElement')
signInWithFingerprintElement: MatCheckbox;
public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string;
public currencies: string[] = [];
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public isWebAuthnEnabled: boolean;
public language = document.documentElement.lang;
public locales = [
'de',
@ -120,7 +117,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
});
}
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) {
public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
@ -158,7 +155,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
});
}
public onRestrictedViewChange(aEvent: MatCheckboxChange) {
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
@ -176,7 +173,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
});
}
public onSignInWithFingerprintChange(aEvent: MatCheckboxChange) {
public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) {
if (aEvent.checked) {
this.registerDevice();
} else {
@ -192,7 +189,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}
}
public onViewModeChange(aEvent: MatCheckboxChange) {
public onViewModeChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntil(this.unsubscribeSubject))
@ -250,9 +247,8 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}
private update() {
if (this.signInWithFingerprintElement) {
this.signInWithFingerprintElement.checked =
this.webAuthnService.isEnabled() ?? false;
}
this.isWebAuthnEnabled = this.webAuthnService.isEnabled() ?? false;
this.changeDetectorRef.markForCheck();
}
}

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

@ -11,12 +11,13 @@
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="user.settings.isRestrictedView"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onRestrictedViewChange($event)"
></mat-checkbox>
></mat-slide-toggle>
</div>
</div>
<div class="d-flex mt-4 py-1">
@ -139,12 +140,13 @@
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="user.settings.viewMode === 'ZEN'"
[disabled]="!hasPermissionToUpdateViewMode"
(change)="onViewModeChange($event)"
></mat-checkbox>
></mat-slide-toggle>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
@ -153,12 +155,13 @@
<div class="hint-text text-muted" i18n>Sign in with fingerprint</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
#toggleSignInWithFingerprintEnabledElement
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="isWebAuthnEnabled === true"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)"
></mat-checkbox>
></mat-slide-toggle>
</div>
</div>
<div
@ -172,12 +175,13 @@
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
<mat-slide-toggle
color="primary"
hideIcon="true"
[checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)"
></mat-checkbox>
></mat-slide-toggle>
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">

4
apps/client/src/app/components/user-account-settings/user-account-settings.module.ts

@ -3,9 +3,9 @@ import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RouterModule } from '@angular/router';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -20,9 +20,9 @@ import { UserAccountSettingsComponent } from './user-account-settings.component'
GfValueModule,
MatButtonModule,
MatCardModule,
MatCheckboxModule,
MatFormFieldModule,
MatSelectModule,
MatSlideToggleModule,
ReactiveFormsModule,
RouterModule
]

2
apps/client/src/app/pages/about/oss-friends/oss-friends-page.html

@ -1,7 +1,7 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h1 class="h3 mb-4 text-center">
<h1 class="h3 line-height-1 mb-4 text-center">
<span class="d-none d-sm-block"
><ng-container i18n>Our</ng-container> OSS Friends</span
>

2
apps/client/src/app/pages/about/overview/about-overview-page.component.ts

@ -1,5 +1,4 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { environment } from '@ghostfolio/client/../environments/environment';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
@ -20,7 +19,6 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public routerLinkFaq = ['/' + $localize`faq`];
public routerLinkFeatures = ['/' + $localize`features`];
public user: User;
public version = environment.version;
private unsubscribeSubject = new Subject<void>();

3
apps/client/src/app/pages/about/overview/about-overview-page.html

@ -35,9 +35,6 @@
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForSubscription"
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio Status"

10
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.component.ts

@ -15,6 +15,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Currency } from '@ghostfolio/common/interfaces/currency.interface';
import { Platform } from '@prisma/client';
import { Observable, Subject } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
@ -30,7 +31,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
})
export class CreateOrUpdateAccountDialog implements OnDestroy {
public accountForm: FormGroup;
public currencies: string[] = [];
public currencies: Currency[] = [];
public filteredPlatforms: Observable<Platform[]>;
public platforms: Platform[];
@ -46,7 +47,10 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
public ngOnInit() {
const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies;
this.currencies = currencies.map((currency) => ({
label: currency,
value: currency
}));
this.platforms = platforms;
this.accountForm = this.formBuilder.group({
@ -101,7 +105,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
const account: CreateAccountDto | UpdateAccountDto = {
balance: this.accountForm.controls['balance'].value,
comment: this.accountForm.controls['comment'].value,
currency: this.accountForm.controls['currency'].value,
currency: this.accountForm.controls['currency'].value?.value,
id: this.accountForm.controls['accountId'].value,
isExcluded: this.accountForm.controls['isExcluded'].value,
name: this.accountForm.controls['name'].value,

11
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html

@ -20,11 +20,10 @@
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Currency</mat-label>
<mat-select formControlName="currency">
<mat-option *ngFor="let currency of currencies" [value]="currency"
>{{ currency }}</mat-option
>
</mat-select>
<gf-currency-selector
formControlName="currency"
[currencies]="currencies"
/>
</mat-form-field>
</div>
<div>
@ -37,7 +36,7 @@
(keydown.enter)="$event.stopPropagation()"
/>
<span class="ml-2" matTextSuffix
>{{ accountForm.controls['currency'].value }}</span
>{{ accountForm.controls['currency']?.value?.value }}</span
>
</mat-form-field>
</div>

6
apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts

@ -1,14 +1,14 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
@ -17,6 +17,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
imports: [
CommonModule,
FormsModule,
GfCurrencySelectorModule,
GfSymbolIconModule,
MatAutocompleteModule,
MatButtonModule,
@ -24,7 +25,6 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule
]
})

19
apps/client/src/app/pages/i18n/i18n-page-routing.module.ts

@ -0,0 +1,19 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { I18nPageComponent } from './i18n-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: I18nPageComponent,
path: ''
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class I18nPageRoutingModule {}

21
apps/client/src/app/pages/i18n/i18n-page.component.ts

@ -0,0 +1,21 @@
import { Component, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
selector: 'gf-i18n-page',
styleUrls: ['./i18n-page.scss'],
templateUrl: './i18n-page.html'
})
export class I18nPageComponent implements OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public ngOnInit() {}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

10
apps/client/src/app/pages/i18n/i18n-page.html

@ -0,0 +1,10 @@
<div class="container">
<div class="row">
<ul>
<li i18n="@@metaDescription">
Ghostfolio is a personal finance dashboard to keep track of your assets
like stocks, ETFs or cryptocurrencies across multiple platforms.
</li>
</ul>
</div>
</div>

12
apps/client/src/app/pages/i18n/i18n-page.module.ts

@ -0,0 +1,12 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { I18nPageRoutingModule } from './i18n-page-routing.module';
import { I18nPageComponent } from './i18n-page.component';
@NgModule({
declarations: [I18nPageComponent],
imports: [CommonModule, I18nPageRoutingModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class I18nPageModule {}

3
apps/client/src/app/pages/i18n/i18n-page.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

10
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -21,10 +21,7 @@
>Stocks, ETFs, bonds, cryptocurrencies, commodities</small
>
</mat-option>
<mat-option
*ngIf="data.user?.settings?.isExperimentalFeatures"
value="FEE"
>
<mat-option value="FEE">
<span><b>{{ typesTranslationMap['FEE'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n
>One-time fee, annual account fees</small
@ -36,10 +33,7 @@
>Distribution of corporate earnings</small
>
</mat-option>
<mat-option
*ngIf="data.user?.settings?.isExperimentalFeatures"
value="INTEREST"
>
<mat-option value="INTEREST">
<span><b>{{ typesTranslationMap['INTEREST'] }}</b></span>
<small class="d-block line-height-1 text-muted text-nowrap" i18n
>Revenue for lending out money</small

4
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -267,6 +267,8 @@ export class ImportActivitiesDialog implements OnDestroy {
return;
} else if (file.name.endsWith('.csv')) {
const content = fileContent.split('\n').slice(1);
try {
const data = await this.importActivitiesService.importCsv({
fileContent,
@ -277,7 +279,7 @@ export class ImportActivitiesDialog implements OnDestroy {
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
activities: error?.activities ?? content,
error: {
error: { message: error?.error?.message ?? [error?.message] }
}

4
apps/client/src/app/services/web-authn.service.ts

@ -88,7 +88,9 @@ export class WebAuthnService {
{ deviceId }
)
.pipe(
switchMap(startAuthentication),
switchMap((requestOptionsJSON) =>
startAuthentication(requestOptionsJSON, true)
),
switchMap((assertionResponse) => {
return this.http.post<{ authToken: string }>(
`/api/v1/auth/webauthn/verify-assertion`,

42
apps/client/src/assets/oss-friends.json

@ -1,5 +1,5 @@
{
"createdAt": "2023-10-05T00:00:00.000Z",
"createdAt": "2023-10-21T00:00:00.000Z",
"data": [
{
"name": "BoxyHQ",
@ -21,11 +21,21 @@
"description": "The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
"href": "https://documenso.com"
},
{
"name": "dyrector.io",
"description": "dyrector.io is an open-source continuous delivery & deployment platform with version management.",
"href": "https://dyrector.io"
},
{
"name": "Erxes",
"description": "The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
"href": "https://erxes.io"
},
{
"name": "Firecamp",
"description": "vscode for apis, open-source postman/insomnia alternative",
"href": "https://firecamp.io"
},
{
"name": "Formbricks",
"description": "Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
@ -46,6 +56,11 @@
"description": "Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.",
"href": "https://www.hanko.io"
},
{
"name": "Hook0",
"description": "Open-Source Webhooks-as-a-service (WaaS) that makes it easy for developers to send webhooks.",
"href": "https://www.hook0.com/"
},
{
"name": "HTMX",
"description": "HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
@ -86,11 +101,21 @@
"description": "Open-source solution to deploy, scale, and operate your multiplayer game.",
"href": "https://rivet.gg"
},
{
"name": "Shelf.nu",
"description": "Open Source Asset and Equipment tracking software that lets you create QR asset labels, manage and overview your assets across locations.",
"href": "https://www.shelf.nu/"
},
{
"name": "Sniffnet",
"description": "Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
"href": "https://www.sniffnet.net"
},
{
"name": "Spark.NET",
"description": "The .NET Web Framework for Makers. Build production ready, full-stack web applications fast without sweating the small stuff.",
"href": "https://spark-framework.net"
},
{
"name": "Tolgee",
"description": "Software localization from A to Z made really easy.",
@ -101,16 +126,16 @@
"description": "Create long-running Jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.",
"href": "https://trigger.dev"
},
{
"name": "Typebot",
"description": "Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
"href": "https://typebot.io"
},
{
"name": "Twenty",
"description": "A modern CRM offering the flexibility of open-source, advanced features and sleek design.",
"href": "https://twenty.com"
},
{
"name": "Typebot",
"description": "Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
"href": "https://typebot.io"
},
{
"name": "Webiny",
"description": "Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.",
@ -120,11 +145,6 @@
"name": "Webstudio",
"description": "Webstudio is an open source alternative to Webflow",
"href": "https://webstudio.is"
},
{
"name": "Spark.NET",
"description": "The .NET Web Framework for Makers. Build production ready, full-stack web applications fast without sweating the small stuff.",
"href": "https://spark-framework.net"
}
],
"source": "https://formbricks.com/api/oss-friends"

3
apps/client/src/environments/environment.prod.ts

@ -1,6 +1,5 @@
export const environment = {
lastPublish: '{BUILD_TIMESTAMP}',
production: true,
stripePublicKey: '',
version: `v${require('../../../../package.json').version}`
stripePublicKey: ''
};

3
apps/client/src/environments/environment.ts

@ -5,8 +5,7 @@
export const environment = {
lastPublish: null,
production: false,
stripePublicKey: '',
version: 'dev'
stripePublicKey: ''
};
/*

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

15
apps/client/src/styles.scss

@ -446,6 +446,12 @@ ngx-skeleton-loader {
.mat-mdc-menu-panel {
.mat-mdc-menu-item {
&.font-weight-bold {
.mat-mdc-menu-item-text {
--mat-menu-item-label-text-weight: 700;
}
}
.mdc-list-item__primary-text {
align-items: center;
display: flex;
@ -462,6 +468,15 @@ ngx-skeleton-loader {
}
}
/**
* Fix for https://github.com/angular/components/issues/26818
*/
.mat-mdc-slide-toggle {
.mdc-switch__track {
background-color: rgba(var(--palette-primary-500), 1);
}
}
.mat-stepper-vertical,
.mat-stepper-horizontal {
background: transparent !important;

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

@ -322,6 +322,15 @@ export function parseDate(date: string): Date | null {
return parseISO(date);
}
export function parseSymbol({ dataSource, symbol }: UniqueAsset) {
const [ticker, exchange] = symbol.split('.');
return {
ticker,
exchange: exchange ?? (dataSource === 'YAHOO' ? 'US' : undefined)
};
}
export function prettifySymbol(aSymbol: string): string {
return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, '');
}

2
libs/common/src/lib/interfaces/admin-market-data.interface.ts

@ -9,9 +9,11 @@ export interface AdminMarketDataItem {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countriesCount: number;
currency: string;
dataSource: DataSource;
date?: Date;
marketDataItemCount: number;
name: string;
sectorsCount: number;
symbol: string;
}

4
libs/common/src/lib/interfaces/currency.interface.ts

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

6
libs/common/src/lib/interfaces/position.interface.ts

@ -1,9 +1,9 @@
import { AssetClass, DataSource } from '@prisma/client';
import { MarketState } from '../types';
import { MarketState } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface Position {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
averagePrice: number;
currency: string;
dataSource: DataSource;

32
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts

@ -7,10 +7,12 @@ import {
EventEmitter,
HostBinding,
Input,
OnChanges,
Output,
ViewChild
} from '@angular/core';
import { Position } from '@ghostfolio/common/interfaces';
import { Params } from '@angular/router';
import { ISearchResultItem } from '@ghostfolio/ui/assistant/interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -18,22 +20,46 @@ import { Position } from '@ghostfolio/common/interfaces';
templateUrl: './assistant-list-item.html',
styleUrls: ['./assistant-list-item.scss']
})
export class AssistantListItemComponent implements FocusableOption {
export class AssistantListItemComponent implements FocusableOption, OnChanges {
@HostBinding('attr.tabindex') tabindex = -1;
@HostBinding('class.has-focus') get getHasFocus() {
return this.hasFocus;
}
@Input() holding: Position;
@Input() item: ISearchResultItem;
@Input() mode: 'assetProfile' | 'holding';
@Output() clicked = new EventEmitter<void>();
@ViewChild('link') public linkElement: ElementRef;
public hasFocus = false;
public queryParams: Params;
public routerLink: string[];
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
public ngOnChanges() {
const dataSource = this.item?.dataSource;
const symbol = this.item?.symbol;
if (this.mode === 'assetProfile') {
this.queryParams = {
dataSource,
symbol,
assetProfileDialog: true
};
this.routerLink = ['/admin', 'market-data'];
} else if (this.mode === 'holding') {
this.queryParams = {
dataSource,
symbol,
positionDetailDialog: true
};
this.routerLink = ['/portfolio', 'holdings'];
}
}
public focus() {
this.hasFocus = true;

20
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html

@ -1,12 +1,16 @@
<a
#link
class="d-block px-2 py-1 text-truncate"
[queryParams]="{
dataSource: holding?.dataSource,
positionDetailDialog: true,
symbol: holding?.symbol
}"
[routerLink]="['/portfolio', 'holdings']"
class="d-block line-height-1 px-2 py-1 text-truncate"
[queryParams]="queryParams"
[routerLink]="routerLink"
(click)="onClick()"
>{{ holding?.name }}</a
><span><b>{{ item?.name }}</b></span>
<br />
<small class="text-muted"
>{{ item?.symbol | gfSymbol }} · {{ item?.currency }}<ng-container
*ngIf="item?.assetSubClassString"
>
· {{ item?.assetSubClassString }}</ng-container
></small
></a
>

3
libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.module.ts

@ -1,12 +1,13 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { AssistantListItemComponent } from './assistant-list-item.component';
@NgModule({
declarations: [AssistantListItemComponent],
exports: [AssistantListItemComponent],
imports: [CommonModule, RouterModule]
imports: [CommonModule, GfSymbolModule, RouterModule]
})
export class GfAssistantListItemModule {}

83
libs/ui/src/lib/assistant/assistant.component.ts

@ -16,9 +16,10 @@ import {
} from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatMenuTrigger } from '@angular/material/menu';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Position } from '@ghostfolio/common/interfaces';
import { EMPTY, Subject, lastValueFrom } from 'rxjs';
import { translate } from '@ghostfolio/ui/i18n';
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import {
catchError,
debounceTime,
@ -29,13 +30,13 @@ import {
} from 'rxjs/operators';
import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component';
import { ISearchResults } from './interfaces/interfaces';
import { ISearchResultItem, ISearchResults } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-assistant',
templateUrl: './assistant.html',
styleUrls: ['./assistant.scss']
styleUrls: ['./assistant.scss'],
templateUrl: './assistant.html'
})
export class AssistantComponent implements OnDestroy, OnInit {
@HostListener('document:keydown', ['$event']) onKeydown(
@ -71,6 +72,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
}
@Input() deviceType: string;
@Input() hasPermissionToAccessAdminControl: boolean;
@Output() closed = new EventEmitter<void>();
@ -87,6 +89,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
public placeholder = $localize`Find holding...`;
public searchFormControl = new FormControl('');
public searchResults: ISearchResults = {
assetProfiles: [],
holdings: []
};
@ -94,6 +97,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
@ -104,6 +108,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
map((searchTerm) => {
this.isLoading = true;
this.searchResults = {
assetProfiles: [],
holdings: []
};
@ -115,6 +120,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
distinctUntilChanged(),
mergeMap(async (searchTerm) => {
const result = <ISearchResults>{
assetProfiles: [],
holdings: []
};
@ -140,6 +146,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
this.isLoading = true;
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
this.searchResults = {
assetProfiles: [],
holdings: []
};
@ -180,10 +187,23 @@ export class AssistantComponent implements OnDestroy, OnInit {
}
private async getSearchResults(aSearchTerm: string) {
let holdings: Position[] = [];
let assetProfiles: ISearchResultItem[] = [];
let holdings: ISearchResultItem[] = [];
if (this.hasPermissionToAccessAdminControl) {
try {
holdings = await lastValueFrom(this.searchHolding(aSearchTerm));
assetProfiles = await lastValueFrom(
this.searchAssetProfiles(aSearchTerm)
);
assetProfiles = assetProfiles.slice(
0,
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
);
} catch {}
}
try {
holdings = await lastValueFrom(this.searchHoldings(aSearchTerm));
holdings = holdings.slice(
0,
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
@ -191,11 +211,46 @@ export class AssistantComponent implements OnDestroy, OnInit {
} catch {}
return {
assetProfiles,
holdings
};
}
private searchHolding(aSearchTerm: string) {
private searchAssetProfiles(
aSearchTerm: string
): Observable<ISearchResultItem[]> {
return this.adminService
.fetchAdminMarketData({
filters: [
{
id: aSearchTerm,
type: 'SEARCH_QUERY'
}
],
take: AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
})
.pipe(
catchError(() => {
return EMPTY;
}),
map(({ marketData }) => {
return marketData.map(
({ assetSubClass, currency, dataSource, name, symbol }) => {
return {
currency,
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass)
};
}
);
}),
takeUntil(this.unsubscribeSubject)
);
}
private searchHoldings(aSearchTerm: string): Observable<ISearchResultItem[]> {
return this.dataService
.fetchPositions({
filters: [
@ -211,7 +266,17 @@ export class AssistantComponent implements OnDestroy, OnInit {
return EMPTY;
}),
map(({ positions }) => {
return positions;
return positions.map(
({ assetSubClass, currency, dataSource, name, symbol }) => {
return {
currency,
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass)
};
}
);
}),
takeUntil(this.unsubscribeSubject)
);

26
libs/ui/src/lib/assistant/assistant.html

@ -45,8 +45,9 @@
<div>
<div class="h6 mb-1 px-2" i18n>Holdings</div>
<gf-assistant-list-item
*ngFor="let holding of searchResults?.holdings"
[holding]="holding"
*ngFor="let searchResultItem of searchResults?.holdings"
mode="holding"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.holdings?.length === 0">
@ -62,5 +63,26 @@
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
<div *ngIf="hasPermissionToAccessAdminControl" class="mt-3">
<div class="h6 mb-1 px-2" i18n>Asset Profiles</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.assetProfiles"
mode="assetProfile"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.assetProfiles?.length === 0">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
</div>
</div>

11
libs/ui/src/lib/assistant/interfaces/interfaces.ts

@ -1,5 +1,12 @@
import { Position } from '@ghostfolio/common/interfaces';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
export interface ISearchResultItem extends UniqueAsset {
assetSubClassString: string;
currency: string;
name: string;
}
export interface ISearchResults {
holdings: Position[];
assetProfiles: ISearchResultItem[];
holdings: ISearchResultItem[];
}

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

@ -0,0 +1,21 @@
<input
autocapitalize="off"
autocomplete="off"
matInput
[formControl]="control"
[matAutocomplete]="currencyAutocomplete"
/>
<mat-autocomplete
#currencyAutocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateCurrency($event)"
>
<mat-option
*ngFor="let currencyItem of filteredCurrencies"
class="line-height-1"
[value]="currencyItem"
>
{{ currencyItem.label }}
</mat-option>
</mat-autocomplete>

3
libs/ui/src/lib/currency-selector/currency-selector.component.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

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

@ -0,0 +1,167 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { FormControl, FormGroupDirective, NgControl } from '@angular/forms';
import {
MatAutocomplete,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatFormFieldControl } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { Currency } from '@ghostfolio/common/interfaces/currency.interface';
import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field';
import { Subject } from 'rxjs';
import { map, startWith, takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[attr.aria-describedBy]': 'describedBy',
'[id]': 'id'
},
providers: [
{
provide: MatFormFieldControl,
useExisting: CurrencySelectorComponent
}
],
selector: 'gf-currency-selector',
styleUrls: ['./currency-selector.component.scss'],
templateUrl: 'currency-selector.component.html'
})
export class CurrencySelectorComponent
extends AbstractMatFormField<Currency>
implements OnInit, OnDestroy
{
@Input() private currencies: Currency[] = [];
@Input() private formControlName: string;
@ViewChild(MatInput) private input: MatInput;
@ViewChild('currencyAutocomplete')
public currencyAutocomplete: MatAutocomplete;
public control = new FormControl();
public filteredCurrencies: Currency[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly _elementRef: ElementRef,
public readonly _focusMonitor: FocusMonitor,
public readonly changeDetectorRef: ChangeDetectorRef,
private readonly formGroupDirective: FormGroupDirective,
public readonly ngControl: NgControl
) {
super(_elementRef, _focusMonitor, ngControl);
this.controlType = 'currency-selector';
}
public ngOnInit() {
if (this.disabled) {
this.control.disable();
}
const formGroup = this.formGroupDirective.form;
if (formGroup) {
const control = formGroup.get(this.formControlName);
if (control) {
this.value = this.currencies.find(({ value }) => {
return value === control.value;
});
}
}
this.control.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
if (super.value?.value) {
super.value.value = null;
}
});
this.control.valueChanges
.pipe(
takeUntil(this.unsubscribeSubject),
startWith(''),
map((value) => {
return value ? this.filter(value) : this.currencies.slice();
})
)
.subscribe((values) => {
this.filteredCurrencies = values;
});
}
public displayFn(currency: Currency) {
return currency?.label ?? '';
}
public get empty() {
return this.input?.empty;
}
public focus() {
this.input.focus();
}
public ngDoCheck() {
if (this.ngControl) {
this.validateRequired();
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.stateChanges.next();
}
}
public onUpdateCurrency(event: MatAutocompleteSelectedEvent) {
super.value = {
label: event.option.value.label,
value: event.option.value.value
} as Currency;
}
public set value(value: Currency) {
const newValue =
typeof value === 'object' && value !== null ? { ...value } : value;
this.control.setValue(newValue);
super.value = newValue;
}
public ngOnDestroy() {
super.ngOnDestroy();
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private filter(value: Currency | string) {
const filterValue =
typeof value === 'string'
? value?.toLowerCase()
: value?.value.toLowerCase();
return this.currencies.filter((currency) => {
return currency.value.toLowerCase().startsWith(filterValue);
});
}
private validateRequired() {
const requiredCheck = super.required
? !super.value.label || !super.value.value
: false;
if (requiredCheck) {
this.ngControl.control.setErrors({ invalidData: true });
}
}
}

23
libs/ui/src/lib/currency-selector/currency-selector.module.ts

@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { CurrencySelectorComponent } from './currency-selector.component';
@NgModule({
declarations: [CurrencySelectorComponent],
exports: [CurrencySelectorComponent],
imports: [
CommonModule,
FormsModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfCurrencySelectorModule {}

2
libs/ui/src/lib/logo/logo.component.html

@ -1,4 +1,4 @@
<span class="align-items-center d-flex"
><span class="d-inline-block logo mr-1"></span>
><span class="d-inline-block logo" [ngClass]="{ 'mr-1': showLabel }"></span>
<span *ngIf="showLabel" class="label">{{ label ?? 'Ghostfolio' }}</span></span
>

8
libs/ui/src/lib/logo/logo.component.scss

@ -4,18 +4,12 @@
}
.logo {
background-color: rgba(var(--dark-primary-text));
background-color: currentColor;
margin-top: -2px;
mask: url('/assets/ghost.svg') no-repeat center;
}
}
:host-context(.is-dark-theme) {
.logo {
background-color: rgba(var(--light-primary-text));
}
}
:host-context(.large) {
.label {
font-size: 3rem;

1
libs/ui/src/lib/membership-card/index.ts

@ -0,0 +1 @@
export * from './membership-card.module';

29
libs/ui/src/lib/membership-card/membership-card.component.html

@ -0,0 +1,29 @@
<div
class="card-container position-relative"
[ngClass]="{ premium: name === 'Premium' }"
>
<a
class="card-item d-flex flex-column justify-content-between p-4"
[routerLink]="routerLinkPricing"
>
<div class="d-flex justify-content-end">
<gf-logo
size="large"
[ngClass]="{ 'text-muted': name === 'Basic' }"
[showLabel]="false"
/>
</div>
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>
<div class="text-truncate value">{{ name }}</div>
</div>
<div *ngIf="expiresAt">
<div class="heading text-muted" i18n>Valid until</div>
<div class="text-truncate value">
{{ expiresAt }}
</div>
</div>
</div>
</a>
</div>

66
libs/ui/src/lib/membership-card/membership-card.component.scss

@ -0,0 +1,66 @@
:host {
--borderRadius: 1rem;
--borderWidth: 2px;
display: block;
max-width: 25rem;
padding-top: calc(1 * var(--borderWidth));
width: 100%;
.card-container {
border-radius: var(--borderRadius);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
&:after {
animation: animatedborder 7s ease alternate infinite;
background: linear-gradient(60deg, #5073b8, #1098ad, #07b39b, #6fba82);
background-size: 300% 300%;
border-radius: var(--borderRadius);
content: '';
height: calc(100% + var(--borderWidth) * 2);
left: calc(-1 * var(--borderWidth));
top: calc(-1 * var(--borderWidth));
position: absolute;
width: calc(100% + var(--borderWidth) * 2);
z-index: -1;
@keyframes animatedborder {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
}
.card-item {
aspect-ratio: 1.586;
background-color: #1d2124;
border-radius: calc(var(--borderRadius) - var(--borderWidth));
color: rgba(var(--light-primary-text));
.heading {
font-size: 13px;
}
.value {
font-size: 18px;
}
}
&:not(.premium) {
&:after {
opacity: 0;
}
.card-item {
background-color: #ffffff;
color: rgba(var(--dark-primary-text));
}
}
}
}

14
libs/ui/src/lib/membership-card/membership-card.component.ts

@ -0,0 +1,14 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-membership-card',
styleUrls: ['./membership-card.component.scss'],
templateUrl: './membership-card.component.html'
})
export class MembershipCardComponent {
@Input() public expiresAt: string;
@Input() public name: string;
public routerLinkPricing = ['/' + $localize`pricing`];
}

14
libs/ui/src/lib/membership-card/membership-card.module.ts

@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { MembershipCardComponent } from './membership-card.component';
@NgModule({
declarations: [MembershipCardComponent],
exports: [MembershipCardComponent],
imports: [CommonModule, GfLogoModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfMembershipCardModule {}

0
libs/ui/src/lib/symbol-autocomplete/abstract-mat-form-field.ts → libs/ui/src/lib/shared/abstract-mat-form-field.ts

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

@ -19,6 +19,7 @@ import { MatInput } from '@angular/material/input';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { translate } from '@ghostfolio/ui/i18n';
import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field';
import { isString } from 'lodash';
import { Subject, tap } from 'rxjs';
import {
@ -29,8 +30,6 @@ import {
takeUntil
} from 'rxjs/operators';
import { AbstractMatFormField } from './abstract-mat-form-field';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
@ -54,7 +53,7 @@ export class SymbolAutocompleteComponent
@Input() private includeIndices = false;
@Input() public isLoading = false;
@ViewChild(MatInput, { static: false }) private input: MatInput;
@ViewChild(MatInput) private input: MatInput;
@ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete;

10
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.11.0",
"version": "2.14.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -82,8 +82,8 @@
"@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.4.2",
"@simplewebauthn/browser": "5.2.1",
"@simplewebauthn/server": "5.2.1",
"@simplewebauthn/browser": "8.3.1",
"@simplewebauthn/server": "8.3.2",
"@stripe/stripe-js": "1.47.0",
"alphavantage": "2.2.0",
"big.js": "6.2.1",
@ -129,7 +129,7 @@
"svgmap": "2.6.0",
"twitter-api-v2": "1.14.2",
"uuid": "9.0.0",
"yahoo-finance2": "2.8.0",
"yahoo-finance2": "2.8.1",
"zone.js": "0.13.1"
},
"devDependencies": {
@ -157,7 +157,7 @@
"@nx/web": "16.7.4",
"@nx/workspace": "16.7.4",
"@schematics/angular": "16.2.0",
"@simplewebauthn/typescript-types": "5.2.1",
"@simplewebauthn/typescript-types": "8.0.0",
"@storybook/addon-essentials": "7.3.2",
"@storybook/angular": "7.3.2",
"@storybook/core-server": "7.3.2",

5
prisma/migrations/20231021094346_added_figi_figi_composite_and_figi_share_class_to_symbol_profile/migration.sql

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "SymbolProfile"
ADD COLUMN "figi" TEXT,
ADD COLUMN "figiComposite" TEXT,
ADD COLUMN "figiShareClass" TEXT;

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save