Browse Source

Merge branch 'main' into enhancement/display-invalid-activity-in-import-of-a-csv-file

pull/2460/head
Thomas Kaul 2 years ago
committed by GitHub
parent
commit
193cc399de
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 68
      CHANGELOG.md
  2. 72
      apps/api/src/app/account/account.controller.ts
  3. 6
      apps/api/src/app/account/account.service.ts
  4. 66
      apps/api/src/app/admin/admin.controller.ts
  5. 2
      apps/api/src/app/admin/admin.module.ts
  6. 35
      apps/api/src/app/admin/admin.service.ts
  7. 11
      apps/api/src/app/admin/update-bulk-market-data.dto.ts
  8. 6
      apps/api/src/app/admin/update-market-data.dto.ts
  9. 30
      apps/api/src/app/auth/web-auth.service.ts
  10. 8
      apps/api/src/app/benchmark/benchmark.service.ts
  11. 1
      apps/api/src/app/portfolio/portfolio.service.ts
  12. 19
      apps/api/src/middlewares/html-template.middleware.ts
  13. 26
      apps/api/src/services/account-balance/account-balance.service.ts
  14. 9
      apps/api/src/services/api/api.service.ts
  15. 67
      apps/api/src/services/i18n/i18n.service.ts
  16. 14
      apps/api/src/services/market-data/market-data.service.ts
  17. 18
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  18. 3
      apps/client/project.json
  19. 5
      apps/client/src/app/app-routing.module.ts
  20. 12
      apps/client/src/app/components/access-table/access-table.component.html
  21. 1
      apps/client/src/app/components/access-table/access-table.component.ts
  22. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.scss
  23. 50
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  24. 11
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  25. 2
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts
  26. 2
      apps/client/src/app/components/accounts-table/accounts-table.component.html
  27. 13
      apps/client/src/app/components/admin-jobs/admin-jobs.html
  28. 6
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
  29. 1
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss
  30. 12
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  31. 14
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  32. 7
      apps/client/src/app/components/admin-market-data/admin-market-data.scss
  33. 34
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  34. 30
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  35. 3
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.scss
  36. 56
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.component.ts
  37. 24
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.html
  38. 4
      apps/client/src/app/components/admin-market-data/create-asset-profile-dialog/create-asset-profile-dialog.module.ts
  39. 30
      apps/client/src/app/components/admin-users/admin-users.component.ts
  40. 208
      apps/client/src/app/components/admin-users/admin-users.html
  41. 4
      apps/client/src/app/components/admin-users/admin-users.module.ts
  42. 3
      apps/client/src/app/components/header/header.component.html
  43. 2
      apps/client/src/app/components/header/header.component.scss
  44. 2
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  45. 12
      apps/client/src/app/components/user-account-access/user-account-access.html
  46. 2
      apps/client/src/app/components/user-account-access/user-account-access.module.ts
  47. 18
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  48. 2
      apps/client/src/app/components/user-account-membership/user-account-membership.module.ts
  49. 24
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  50. 22
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  51. 4
      apps/client/src/app/components/user-account-settings/user-account-settings.module.ts
  52. 19
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  53. 2
      apps/client/src/app/pages/accounts/accounts-page.html
  54. 7
      apps/client/src/app/pages/accounts/accounts-page.scss
  55. 4
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.html
  56. 2
      apps/client/src/app/pages/accounts/create-or-update-account-dialog/create-or-update-account-dialog.module.ts
  57. 19
      apps/client/src/app/pages/i18n/i18n-page-routing.module.ts
  58. 21
      apps/client/src/app/pages/i18n/i18n-page.component.ts
  59. 10
      apps/client/src/app/pages/i18n/i18n-page.html
  60. 12
      apps/client/src/app/pages/i18n/i18n-page.module.ts
  61. 3
      apps/client/src/app/pages/i18n/i18n-page.scss
  62. 7
      apps/client/src/app/pages/portfolio/activities/activities-page.scss
  63. 15
      apps/client/src/app/services/admin.service.ts
  64. 13
      apps/client/src/app/services/data.service.ts
  65. 4
      apps/client/src/app/services/import-activities.service.ts
  66. 4
      apps/client/src/app/services/web-authn.service.ts
  67. 1116
      apps/client/src/locales/messages.de.xlf
  68. 1108
      apps/client/src/locales/messages.es.xlf
  69. 1114
      apps/client/src/locales/messages.fr.xlf
  70. 1114
      apps/client/src/locales/messages.it.xlf
  71. 1114
      apps/client/src/locales/messages.nl.xlf
  72. 1114
      apps/client/src/locales/messages.pt.xlf
  73. 1120
      apps/client/src/locales/messages.tr.xlf
  74. 1094
      apps/client/src/locales/messages.xlf
  75. 22
      apps/client/src/styles.scss
  76. 2
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  77. 1
      libs/common/src/lib/interfaces/benchmark.interface.ts
  78. 2
      libs/common/src/lib/interfaces/index.ts
  79. 6
      libs/common/src/lib/interfaces/position.interface.ts
  80. 5
      libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts
  81. 32
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts
  82. 20
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html
  83. 3
      libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.module.ts
  84. 83
      libs/ui/src/lib/assistant/assistant.component.ts
  85. 26
      libs/ui/src/lib/assistant/assistant.html
  86. 11
      libs/ui/src/lib/assistant/interfaces/interfaces.ts
  87. 27
      libs/ui/src/lib/benchmark/benchmark.component.html
  88. 2
      libs/ui/src/lib/benchmark/benchmark.component.ts
  89. 6
      libs/ui/src/lib/logo/logo.component.html
  90. 1
      libs/ui/src/lib/logo/logo.component.ts
  91. 1
      libs/ui/src/lib/membership-card/index.ts
  92. 25
      libs/ui/src/lib/membership-card/membership-card.component.html
  93. 24
      libs/ui/src/lib/membership-card/membership-card.component.scss
  94. 14
      libs/ui/src/lib/membership-card/membership-card.component.ts
  95. 14
      libs/ui/src/lib/membership-card/membership-card.module.ts
  96. 12
      package.json
  97. 11
      test/import/ok.csv
  98. 279
      yarn.lock

68
CHANGELOG.md

@ -10,6 +10,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the error message in the activities import for `csv` files - Improved the error message in the activities import for `csv` files
- Allowed to edit today’s historical market data in the asset profile details dialog of the admin control panel
## 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
### Added
- Added support to transfer a part of the cash balance from one to another account
- Extended the markets overview by benchmarks (date of last all time high)
- Added support to import historical market data in the admin control panel
### Changed
- Harmonized the style of the create button on the page for granting and revoking public access to share the portfolio
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.3.1` to `5.4.2`
### Fixed
- Fixed `FEE` and `INTEREST` types in the activities import of `csv` files
- Fixed the displayed currency of the cash balance in the create or update account dialog
## 2.10.0 - 2023-10-09 ## 2.10.0 - 2023-10-09
@ -113,13 +169,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the preselected currency based on the account's currency in the create or edit activity dialog - Improved the preselected currency based on the accounts currency in the create or edit activity dialog
- Unlocked the experimental features setting for all users - Unlocked the experimental features setting for all users
- Upgraded `prisma` from version `5.2.0` to `5.3.1` - Upgraded `prisma` from version `5.2.0` to `5.3.1`
### Fixed ### Fixed
- Fixed a memory leak related to the server's timezone (behind UTC) in the data gathering - Fixed a memory leak related to the servers timezone (behind UTC) in the data gathering
## 2.3.0 - 2023-09-17 ## 2.3.0 - 2023-09-17
@ -270,7 +326,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Optimized the activities import by allowing a different currency than the asset's official one - Optimized the activities import by allowing a different currency than the assets official one
- Added a timeout to the _EOD Historical Data_ requests - Added a timeout to the _EOD Historical Data_ requests
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service - Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
@ -777,7 +833,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Persisted today's market data continuously - Persisted todays market data continuously
### Fixed ### Fixed
@ -1011,7 +1067,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Filtered activities with type `ITEM` from search results - Filtered activities with type `ITEM` from search results
- Considered the user's language in the _Stripe_ checkout - Considered the users language in the _Stripe_ checkout
- Upgraded the _Stripe_ dependencies - Upgraded the _Stripe_ dependencies
- Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2` - Upgraded `twitter-api-v2` from version `1.10.3` to `1.14.2`
@ -2685,7 +2741,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Moved the countries and sectors charts in the position detail dialog - Moved the countries and sectors charts in the position detail dialog
- Distinguished today's data point of historical data in the admin control panel - Distinguished todays data point of historical data in the admin control panel
- Restructured the server modules - Restructured the server modules
### Fixed ### Fixed

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

@ -1,8 +1,12 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { Accounts } from '@ghostfolio/common/interfaces'; import {
AccountBalancesResponse,
Accounts
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
AccountWithValue, AccountWithValue,
@ -29,11 +33,13 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto'; import { CreateAccountDto } from './create-account.dto';
import { TransferBalanceDto } from './transfer-balance.dto';
import { UpdateAccountDto } from './update-account.dto'; import { UpdateAccountDto } from './update-account.dto';
@Controller('account') @Controller('account')
export class AccountController { export class AccountController {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@ -115,6 +121,18 @@ export class AccountController {
return accountsWithAggregations.accounts[0]; return accountsWithAggregations.accounts[0];
} }
@Get(':id/balances')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
public async getAccountBalancesById(
@Param('id') id: string
): Promise<AccountBalancesResponse> {
return this.accountBalanceService.getAccountBalances({
accountId: id,
userId: this.request.user.id
});
}
@Post() @Post()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async createAccount( public async createAccount(
@ -154,6 +172,58 @@ export class AccountController {
} }
} }
@Post('transfer-balance')
@UseGuards(AuthGuard('jwt'))
public async transferAccountBalance(
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
) {
if (
!hasPermission(this.request.user.permissions, permissions.updateAccount)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const accountsOfUser = await this.accountService.getAccounts(
this.request.user.id
);
const currentAccountIds = accountsOfUser.map(({ id }) => {
return id;
});
if (
![accountIdFrom, accountIdTo].every((accountId) => {
return currentAccountIds.includes(accountId);
})
) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const { currency } = accountsOfUser.find(({ id }) => {
return id === accountIdFrom;
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdFrom,
amount: -balance,
userId: this.request.user.id
});
await this.accountService.updateAccountBalance({
currency,
accountId: accountIdTo,
amount: balance,
userId: this.request.user.id
});
}
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {

6
apps/api/src/app/account/account.service.ts

@ -109,7 +109,7 @@ export class AccountService {
}); });
} }
public async getAccounts(aUserId: string) { public async getAccounts(aUserId: string): Promise<Account[]> {
const accounts = await this.accounts({ const accounts = await this.accounts({
include: { Order: true, Platform: true }, include: { Order: true, Platform: true },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
@ -218,13 +218,13 @@ export class AccountService {
accountId, accountId,
amount, amount,
currency, currency,
date, date = new Date(),
userId userId
}: { }: {
accountId: string; accountId: string;
amount: number; amount: number;
currency: string; currency: string;
date: Date; date?: Date;
userId: string; userId: string;
}) { }) {
const { balance, currency: currencyOfAccount } = await this.account({ const { balance, currency: currencyOfAccount } = await this.account({

66
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 { 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 { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -12,8 +12,7 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile, EnhancedSymbolProfile
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -43,12 +42,14 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
import { UpdateAssetProfileDto } from './update-asset-profile.dto'; import { UpdateAssetProfileDto } from './update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto';
import { UpdateMarketDataDto } from './update-market-data.dto'; import { UpdateMarketDataDto } from './update-market-data.dto';
@Controller('admin') @Controller('admin')
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -254,6 +255,7 @@ export class AdminController {
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -271,16 +273,10 @@ export class AdminController {
); );
} }
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
const filters: Filter[] = [ filterBySearchQuery
...assetSubClasses.map((assetSubClass) => { });
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
return this.adminService.getMarketData({ return this.adminService.getMarketData({
filters, filters,
@ -313,6 +309,43 @@ export class AdminController {
return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
} }
@Post('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async updateMarketData(
@Body() data: UpdateBulkMarketDataDto,
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
) {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
({ date, marketPrice }) => ({
dataSource,
date,
marketPrice,
symbol,
state: 'CLOSE'
})
);
return this.marketDataService.updateMany({
data: dataBulkUpdate
});
}
/**
* @deprecated
*/
@Put('market-data/:dataSource/:symbol/:dateString') @Put('market-data/:dataSource/:symbol/:dateString')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async update( public async update(
@ -365,8 +398,11 @@ export class AdminController {
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN
); );
} }
return this.adminService.addAssetProfile({
return this.adminService.addAssetProfile({ dataSource, symbol }); dataSource,
symbol,
currency: this.request.user.Settings.settings.baseCurrency
});
} }
@Delete('profile-data/:dataSource/:symbol') @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 { 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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ApiModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,

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

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

11
apps/api/src/app/admin/update-bulk-market-data.dto.ts

@ -0,0 +1,11 @@
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, isNotEmptyObject } from 'class-validator';
import { UpdateMarketDataDto } from './update-market-data.dto';
export class UpdateBulkMarketDataDto {
@ArrayNotEmpty()
@IsArray()
@Type(() => UpdateMarketDataDto)
marketData: UpdateMarketDataDto[];
}

6
apps/api/src/app/admin/update-market-data.dto.ts

@ -1,6 +1,10 @@
import { IsNumber } from 'class-validator'; import { IsDate, IsNumber, IsOptional } from 'class-validator';
export class UpdateMarketDataDto { export class UpdateMarketDataDto {
@IsDate()
@IsOptional()
date?: Date;
@IsNumber() @IsNumber()
marketPrice: number; marketPrice: number;
} }

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

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

@ -64,7 +64,7 @@ export class BenchmarkService {
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles(); const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
const promises: Promise<number>[] = []; const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
@ -85,15 +85,14 @@ export class BenchmarkService {
let performancePercentFromAllTimeHigh = 0; let performancePercentFromAllTimeHigh = 0;
if (allTimeHigh && marketPrice) { if (allTimeHigh?.marketPrice && marketPrice) {
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage( performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
allTimeHigh, allTimeHigh.marketPrice,
marketPrice marketPrice
); );
} else { } else {
storeInCache = false; storeInCache = false;
} }
return { return {
marketCondition: this.getMarketCondition( marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh performancePercentFromAllTimeHigh
@ -101,6 +100,7 @@ export class BenchmarkService {
name: benchmarkAssetProfiles[index].name, name: benchmarkAssetProfiles[index].name,
performances: { performances: {
allTimeHigh: { allTimeHigh: {
date: allTimeHigh.date,
performancePercent: performancePercentFromAllTimeHigh performancePercent: performancePercentFromAllTimeHigh
} }
} }

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

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

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

@ -2,6 +2,7 @@ import * as fs from 'fs';
import { join } from 'path'; import { join } from 'path';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL, DEFAULT_ROOT_URL,
@ -11,20 +12,11 @@ import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express'; 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 title = 'Ghostfolio – Open Source Wealth Management Software';
const titleShort = 'Ghostfolio'; const titleShort = 'Ghostfolio';
const i18nService = new I18nService();
let indexHtmlMap: { [languageCode: string]: string } = {}; let indexHtmlMap: { [languageCode: string]: string } = {};
try { try {
@ -130,7 +122,10 @@ export const HtmlTemplateMiddleware = async (
languageCode, languageCode,
path, path,
rootUrl, rootUrl,
description: descriptions[languageCode], description: i18nService.getTranslation({
languageCode,
id: 'metaDescription'
}),
featureGraphicPath: featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png', locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title title: locales[path]?.title ?? title

26
apps/api/src/services/account-balance/account-balance.service.ts

@ -1,4 +1,5 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client'; import { AccountBalance, Prisma } from '@prisma/client';
@ -13,4 +14,29 @@ export class AccountBalanceService {
data data
}); });
} }
public async getAccountBalances({
accountId,
userId
}: {
accountId: string;
userId: string;
}): Promise<AccountBalancesResponse> {
const balances = await this.prismaService.accountBalance.findMany({
orderBy: {
date: 'asc'
},
select: {
date: true,
id: true,
value: true
},
where: {
accountId,
userId
}
});
return { balances };
}
} }

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

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

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

14
apps/api/src/services/market-data/market-data.service.ts

@ -39,18 +39,22 @@ export class MarketDataService {
}); });
} }
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> { public async getMax({ dataSource, symbol }: UniqueAsset) {
const aggregations = await this.prismaService.marketData.aggregate({ return this.prismaService.marketData.findFirst({
_max: { select: {
date: true,
marketPrice: true marketPrice: true
}, },
orderBy: [
{
marketPrice: 'desc'
}
],
where: { where: {
dataSource, dataSource,
symbol symbol
} }
}); });
return aggregations._max.marketPrice;
} }
public async getRange({ public async getRange({

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

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

3
apps/client/project.json

@ -124,6 +124,9 @@
{ {
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client" "command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
}, },
{
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
},
{ {
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client" "command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
}, },

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

@ -73,6 +73,11 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) 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, path: paths.markets,
loadChildren: () => loadChildren: () =>

12
apps/client/src/app/components/access-table/access-table.component.html

@ -1,15 +1,3 @@
<div *ngIf="hasPermissionToCreateAccess" class="d-flex justify-content-end">
<a
color="primary"
i18n
mat-flat-button
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
Add Access
</a>
</div>
<table class="gf-table w-100" mat-table [dataSource]="dataSource"> <table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="alias"> <ng-container matColumnDef="alias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Alias</th>

1
apps/client/src/app/components/access-table/access-table.component.ts

@ -19,7 +19,6 @@ import { Access } from '@ghostfolio/common/interfaces';
}) })
export class AccessTableComponent implements OnChanges, OnInit { export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[]; @Input() accesses: Access[];
@Input() hasPermissionToCreateAccess = false;
@Input() showActions: boolean; @Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>(); @Output() accessDeleted = new EventEmitter<string>();

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

@ -3,5 +3,9 @@
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
max-height: unset; 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'; } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service'; 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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper'; 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 { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import Big from 'big.js'; import Big from 'big.js';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -32,6 +32,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public balance: number; public balance: number;
public currency: string; public currency: string;
public equity: number; public equity: number;
public hasImpersonationId: boolean;
public historicalDataItems: HistoricalDataItem[];
public isLoadingChart: boolean;
public name: string; public name: string;
public orders: OrderWithAccount[]; public orders: OrderWithAccount[];
public platformName: string; public platformName: string;
@ -46,6 +49,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>, public dialogRef: MatDialogRef<AccountDetailDialog>,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
@ -59,7 +63,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}); });
} }
public ngOnInit(): void { public ngOnInit() {
this.isLoadingChart = true;
this.dataService this.dataService
.fetchAccount(this.data.accountId) .fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -101,9 +107,45 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); 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(); this.dialogRef.close();
} }

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

@ -20,6 +20,17 @@
</div> </div>
</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="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <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 { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.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 { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -17,6 +18,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
GfActivitiesTableModule, GfActivitiesTableModule,
GfDialogFooterModule, GfDialogFooterModule,
GfDialogHeaderModule, GfDialogHeaderModule,
GfInvestmentChartModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,

2
apps/client/src/app/components/accounts-table/accounts-table.component.html

@ -1,4 +1,4 @@
<div *ngIf="false" class="d-flex justify-content-end"> <div *ngIf="showActions" class="d-flex justify-content-end">
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-stroked-button mat-stroked-button

13
apps/client/src/app/components/admin-jobs/admin-jobs.html

@ -28,20 +28,15 @@
<ng-container i18n>Type</ng-container> <ng-container i18n>Type</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 py-2" mat-cell> <td *matCellDef="let element" class="px-1 py-2" mat-cell>
<span class="align-items-center d-flex"> <ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'" i18n>
<ion-icon Asset Profile
class="mr-1"
name="arrow-down-circle-outline"
></ion-icon>
<ng-container *ngIf="element.name === 'GATHER_ASSET_PROFILE'">
<span i18n>Asset Profile</span>
</ng-container> </ng-container>
<ng-container <ng-container
*ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'" *ngIf="element.name === 'GATHER_HISTORICAL_MARKET_DATA'"
i18n
> >
<span i18n>Historical Market Data</span> Historical Market Data
</ng-container> </ng-container>
</span>
</td> </td>
</ng-container> </ng-container>

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

@ -9,7 +9,11 @@
[showYAxis]="true" [showYAxis]="true"
[symbol]="symbol" [symbol]="symbol"
></gf-line-chart> ></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="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 px-1"> <div class="align-items-center d-flex flex-grow-1 px-1">
<div <div

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

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

12
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() { public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale); this.defaultDateFormat = getDateFormatString(this.locale);
this.historicalDataItems = this.marketData.map((marketDataItem) => { this.historicalDataItems = this.marketData.map(({ date, marketPrice }) => {
return { return {
date: format(marketDataItem.date, DATE_FORMAT), date: format(date, DATE_FORMAT),
value: marketDataItem.marketPrice value: marketPrice
}; };
}); });
@ -157,10 +157,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
const date = parseISO(`${yearMonth}-${day}`); const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) {
return;
}
const dialogRef = this.dialog.open(MarketDataDetailDialog, { const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: <MarketDataDetailDialogParams>{ data: <MarketDataDetailDialogParams>{
date, date,
@ -177,7 +173,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ withRefresh }) => { .subscribe(({ withRefresh } = { withRefresh: false }) => {
this.marketDataChanged.next(withRefresh); this.marketDataChanged.next(withRefresh);
}); });
} }

14
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) { public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
const confirmation = confirm(
$localize`Do you really want to delete this asset profile?`
);
if (confirmation) {
this.adminService this.adminService
.deleteProfileData({ dataSource, symbol }) .deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
} }
public onGather7Days() { public onGather7Days() {
@ -342,7 +352,7 @@ export class AdminMarketDataComponent
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol }) => { .subscribe(({ dataSource, symbol } = {}) => {
if (dataSource && symbol) { if (dataSource && symbol) {
this.adminService this.adminService
.addAssetProfile({ dataSource, symbol }) .addAssetProfile({ dataSource, symbol })

7
apps/client/src/app/components/admin-market-data/admin-market-data.scss

@ -2,11 +2,4 @@
:host { :host {
display: block; display: block;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
} }

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

@ -11,12 +11,15 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { MarketData, SymbolProfile } from '@prisma/client'; import { MarketData, SymbolProfile } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -42,12 +45,17 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public countries: { public countries: {
[code: string]: { name: string; value: number }; [code: string]: { name: string; value: number };
}; };
public historicalDataAsCsvString: string;
public isBenchmark = false; public isBenchmark = false;
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
)};123.45`;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -66,6 +74,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
public initialize() { public initialize() {
this.historicalDataAsCsvString =
AssetProfileDialog.HISTORICAL_DATA_TEMPLATE;
this.adminService this.adminService
.fetchAdminMarketDataBySymbol({ .fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@ -134,6 +145,29 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe(() => {}); .subscribe(() => {});
} }
public onImportHistoricalData() {
const marketData = csvToJson(this.historicalDataAsCsvString, {
dynamicTyping: true,
header: true,
skipEmptyLines: true
}).data;
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData: marketData.map(({ date, marketPrice }) => {
return { marketPrice, date: parseISO(date) };
})
},
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.initialize();
});
}
public onMarketDataChanged(withRefresh: boolean = false) { public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();

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

@ -51,6 +51,36 @@
[symbol]="data.symbol" [symbol]="data.symbol"
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"
></gf-admin-market-data-detail> ></gf-admin-market-data-detail>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
</mat-label>
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
matInput
placeholder="e.g. 20230601;1.61"
type="text"
[ngModelOptions]="{standalone: true}"
[(ngModel)]="historicalDataAsCsvString"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="d-flex justify-content-end mt-2">
<button
color="accent"
mat-flat-button
type="button"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol" <gf-value i18n size="medium" [value]="assetProfile?.symbol"

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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
Inject,
OnDestroy, OnDestroy,
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { import {
AbstractControl,
FormBuilder, FormBuilder,
FormControl, FormControl,
FormGroup, FormGroup,
ValidationErrors,
Validators Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog'; import { MatDialogRef } from '@angular/material/dialog';
@ -19,35 +19,75 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' }, host: { class: 'h-100' },
selector: 'gf-create-asset-profile-dialog', selector: 'gf-create-asset-profile-dialog',
styleUrls: ['./create-asset-profile-dialog.component.scss'],
templateUrl: 'create-asset-profile-dialog.html' templateUrl: 'create-asset-profile-dialog.html'
}) })
export class CreateAssetProfileDialog implements OnInit, OnDestroy { export class CreateAssetProfileDialog implements OnInit, OnDestroy {
public createAssetProfileForm: FormGroup; public createAssetProfileForm: FormGroup;
public mode: 'auto' | 'manual';
public constructor( public constructor(
public readonly adminService: AdminService, public readonly adminService: AdminService,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>, public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
public readonly formBuilder: FormBuilder public readonly formBuilder: FormBuilder
) {} ) {}
public ngOnInit() { public ngOnInit() {
this.createAssetProfileForm = this.formBuilder.group({ this.createAssetProfileForm = this.formBuilder.group(
{
addSymbol: new FormControl(null, [Validators.required]),
searchSymbol: new FormControl(null, [Validators.required]) searchSymbol: new FormControl(null, [Validators.required])
}); },
{
validators: this.atLeastOneValid
}
);
this.mode = 'auto';
} }
public onCancel() { public onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
public onRadioChange(mode: 'auto' | 'manual') {
this.mode = mode;
}
public onSubmit() { public onSubmit() {
this.dialogRef.close({ this.mode === 'auto'
? this.dialogRef.close({
dataSource: dataSource:
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource, this.createAssetProfileForm.controls['searchSymbol'].value
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol .dataSource,
symbol:
this.createAssetProfileForm.controls['searchSymbol'].value.symbol
})
: this.dialogRef.close({
dataSource: 'MANUAL',
symbol: this.createAssetProfileForm.controls['addSymbol'].value
}); });
} }
public ngOnDestroy() {} 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> <h1 i18n mat-dialog-title>Add Asset Profile</h1>
<div class="flex-grow-1 py-3" mat-dialog-content> <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-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label> <mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete <gf-symbol-autocomplete
@ -14,13 +29,20 @@
/> />
</mat-form-field> </mat-form-field>
</div> </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> <div class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button <button
color="primary" color="primary"
mat-flat-button mat-flat-button
type="submit" type="submit"
[disabled]="!createAssetProfileForm.valid" [disabled]="createAssetProfileForm.hasError('atLeastOneValid')"
> >
<ng-container i18n>Save</ng-container> <ng-container i18n>Save</ng-container>
</button> </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 { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; 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 { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component'; import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
@ -17,6 +19,8 @@ import { CreateAssetProfileDialog } from './create-asset-profile-dialog.componen
MatDialogModule, MatDialogModule,
MatButtonModule, MatButtonModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule,
MatRadioModule,
ReactiveFormsModule ReactiveFormsModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

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

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

@ -2,107 +2,193 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="users"> <div class="users">
<table class="gf-table"> <table class="gf-table" mat-table [dataSource]="dataSource">
<thead> <ng-container matColumnDef="index">
<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>
<th <th
*ngIf="hasPermissionForSubscription" *matHeaderCellDef
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"
class="mat-mdc-header-cell px-1 py-2 text-right" class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
> >
<ng-container i18n>Engagement per Day</ng-container> #
</th> </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 <th
*ngIf="hasPermissionForSubscription" *matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2" class="mat-mdc-header-cell px-1 py-2"
i18n i18n
mat-header-cell
> >
Last Request User
</th> </th>
<th class="mat-mdc-header-cell px-1 py-2"></th> <td
</tr> *matCellDef="let element"
</thead> class="mat-mdc-cell px-1 py-2"
<tbody> mat-cell
<tr
*ngFor="let userItem of users; let i = index"
class="mat-mdc-row"
> >
<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"> <div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block text-monospace" <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" <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 <gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'" *ngIf="element?.subscription?.type === 'Premium'"
class="ml-1" class="ml-1"
[enableLink]="false" [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> ></gf-premium-indicator>
</div> </div>
</td> </td>
<td </ng-container>
<ng-container
*ngIf="hasPermissionForSubscription" *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" class="mat-mdc-cell px-1 py-2"
mat-cell
> >
<span class="h5" [title]="userItem.country" <span class="h5" [title]="element.country"
>{{ getEmojiFlag(userItem.country) }}</span >{{ getEmojiFlag(element.country) }}</span
> >
</td> </td>
<td class="mat-mdc-cell px-1 py-2"> </ng-container>
{{ formatDistanceToNow(userItem.createdAt) }}
<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>
<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 <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="userItem.accountCount" [value]="element.accountCount"
></gf-value> ></gf-value>
</td> </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 <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="userItem.transactionCount" [value]="element.transactionCount"
></gf-value> ></gf-value>
</td> </td>
<td </ng-container>
<ng-container
*ngIf="hasPermissionForSubscription" *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" class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
> >
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[precision]="0" [precision]="0"
[value]="userItem.engagement" [value]="element.engagement"
></gf-value> ></gf-value>
</td> </td>
<td </ng-container>
<ng-container
*ngIf="hasPermissionForSubscription" *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" class="mat-mdc-cell px-1 py-2"
mat-cell
> >
{{ formatDistanceToNow(userItem.lastActivity) }} {{ formatDistanceToNow(element.lastActivity) }}
</td> </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 <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
@ -115,23 +201,33 @@
<button <button
*ngIf="hasPermissionToImpersonateAllUsers" *ngIf="hasPermissionToImpersonateAllUsers"
mat-menu-item mat-menu-item
(click)="onImpersonateUser(userItem.id)" (click)="onImpersonateUser(element.id)"
> >
<ion-icon class="mr-2" name="contract-outline"></ion-icon> <ion-icon class="mr-2" name="contract-outline"></ion-icon>
<span i18n>Impersonate User</span> <span i18n>Impersonate User</span>
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="userItem.id === user?.id" [disabled]="element.id === user?.id"
(click)="onDeleteUser(userItem.id)" (click)="onDeleteUser(element.id)"
> >
<ion-icon class="mr-2" name="trash-outline"></ion-icon> <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete User</span> <span i18n>Delete User</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>
</tr> </ng-container>
</tbody>
<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> </table>
</div> </div>
</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 { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -15,7 +16,8 @@ import { AdminUsersComponent } from './admin-users.component';
GfPremiumIndicatorModule, GfPremiumIndicatorModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatMenuModule MatMenuModule,
MatTableModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

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

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

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

@ -22,7 +22,7 @@
} }
.mdc-button { .mdc-button {
height: unset; height: 100%;
&:not(.mat-primary) { &:not(.mat-primary) {
background-color: transparent; background-color: transparent;

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

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

12
apps/client/src/app/components/user-account-access/user-account-access.html

@ -10,8 +10,18 @@
</h1> </h1>
<gf-access-table <gf-access-table
[accesses]="accesses" [accesses]="accesses"
[hasPermissionToCreateAccess]="hasPermissionToCreateAccess"
[showActions]="hasPermissionToDeleteAccess" [showActions]="hasPermissionToDeleteAccess"
(accessDeleted)="onDeleteAccess($event)" (accessDeleted)="onDeleteAccess($event)"
></gf-access-table> ></gf-access-table>
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div> </div>

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

@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
@ -16,6 +17,7 @@ import { UserAccountAccessComponent } from './user-account-access.component';
GfCreateOrUpdateAccessDialogModule, GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule, GfPortfolioAccessTableModule,
GfPremiumIndicatorModule, GfPremiumIndicatorModule,
MatButtonModule,
MatDialogModule, MatDialogModule,
RouterModule RouterModule
] ]

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

@ -2,21 +2,13 @@
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1> <h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<gf-membership-card
class="mb-5 mx-auto"
[expiresAt]="(user?.subscription?.expiresAt | date: defaultDateFormat) ?? '∞'"
[name]="user?.subscription?.type"
></gf-membership-card>
<div class="d-flex"> <div class="d-flex">
<div class="mx-auto"> <div class="mx-auto">
<div class="align-items-center d-flex mb-1">
<a [routerLink]="routerLinkPricing"
>{{ user?.subscription?.type }}</a
>
<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'"> <div *ngIf="user?.subscription?.type === 'Basic'">
<ng-container <ng-container
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings" *ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"

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

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

@ -3,10 +3,9 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
OnDestroy, OnDestroy,
OnInit, OnInit
ViewChild
} from '@angular/core'; } 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 { DataService } from '@ghostfolio/client/services/data.service';
import { import {
STAY_SIGNED_IN, STAY_SIGNED_IN,
@ -29,14 +28,12 @@ import { catchError, takeUntil } from 'rxjs/operators';
templateUrl: './user-account-settings.html' templateUrl: './user-account-settings.html'
}) })
export class UserAccountSettingsComponent implements OnDestroy, OnInit { export class UserAccountSettingsComponent implements OnDestroy, OnInit {
@ViewChild('toggleSignInWithFingerprintEnabledElement')
signInWithFingerprintElement: MatCheckbox;
public appearancePlaceholder = $localize`Auto`; public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string; public baseCurrency: string;
public currencies: string[] = []; public currencies: string[] = [];
public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public isWebAuthnEnabled: boolean;
public language = document.documentElement.lang; public language = document.documentElement.lang;
public locales = [ public locales = [
'de', 'de',
@ -120,7 +117,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}); });
} }
public onExperimentalFeaturesChange(aEvent: MatCheckboxChange) { public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ isExperimentalFeatures: aEvent.checked }) .putUserSetting({ isExperimentalFeatures: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -158,7 +155,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}); });
} }
public onRestrictedViewChange(aEvent: MatCheckboxChange) { public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked }) .putUserSetting({ isRestrictedView: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject)) .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) { if (aEvent.checked) {
this.registerDevice(); this.registerDevice();
} else { } else {
@ -192,7 +189,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
} }
} }
public onViewModeChange(aEvent: MatCheckboxChange) { public onViewModeChange(aEvent: MatSlideToggleChange) {
this.dataService this.dataService
.putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' }) .putUserSetting({ viewMode: aEvent.checked === true ? 'ZEN' : 'DEFAULT' })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -250,9 +247,8 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
} }
private update() { private update() {
if (this.signInWithFingerprintElement) { this.isWebAuthnEnabled = this.webAuthnService.isEnabled() ?? false;
this.signInWithFingerprintElement.checked =
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> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="user.settings.isRestrictedView" [checked]="user.settings.isRestrictedView"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
(change)="onRestrictedViewChange($event)" (change)="onRestrictedViewChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="d-flex mt-4 py-1"> <div class="d-flex mt-4 py-1">
@ -139,12 +140,13 @@
</div> </div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="user.settings.viewMode === 'ZEN'" [checked]="user.settings.viewMode === 'ZEN'"
[disabled]="!hasPermissionToUpdateViewMode" [disabled]="!hasPermissionToUpdateViewMode"
(change)="onViewModeChange($event)" (change)="onViewModeChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">
@ -153,12 +155,13 @@
<div class="hint-text text-muted" i18n>Sign in with fingerprint</div> <div class="hint-text text-muted" i18n>Sign in with fingerprint</div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-checkbox <mat-slide-toggle
#toggleSignInWithFingerprintEnabledElement
color="primary" color="primary"
hideIcon="true"
[checked]="isWebAuthnEnabled === true"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)" (change)="onSignInWithFingerprintChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div <div
@ -172,12 +175,13 @@
</div> </div>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<mat-checkbox <mat-slide-toggle
color="primary" color="primary"
hideIcon="true"
[checked]="user.settings.isExperimentalFeatures" [checked]="user.settings.isExperimentalFeatures"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateUserSettings"
(change)="onExperimentalFeaturesChange($event)" (change)="onExperimentalFeaturesChange($event)"
></mat-checkbox> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1"> <div class="align-items-center d-flex mt-4 py-1">

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

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

@ -30,7 +30,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateAccount: boolean; public hasPermissionToCreateAccount: boolean;
public hasPermissionToDeleteAccount: boolean; public hasPermissionToUpdateAccount: boolean;
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public totalBalanceInBaseCurrency = 0; public totalBalanceInBaseCurrency = 0;
public totalValueInBaseCurrency = 0; public totalValueInBaseCurrency = 0;
@ -95,9 +95,9 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.user.permissions, this.user.permissions,
permissions.createAccount permissions.createAccount
); );
this.hasPermissionToDeleteAccount = hasPermission( this.hasPermissionToUpdateAccount = hasPermission(
this.user.permissions, this.user.permissions,
permissions.deleteAccount permissions.updateAccount
); );
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
@ -295,9 +295,16 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
const { accountIdFrom, accountIdTo, balance }: TransferBalanceDto = const { accountIdFrom, accountIdTo, balance }: TransferBalanceDto =
data?.account; data?.account;
console.log( this.dataService
`Transfer cash balance of ${balance} from account ${accountIdFrom} to account ${accountIdTo}` .transferAccountBalance({
); accountIdFrom,
accountIdTo,
balance
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.fetchAccounts();
});
} }
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });

2
apps/client/src/app/pages/accounts/accounts-page.html

@ -8,7 +8,7 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView" [showActions]="!hasImpersonationId && hasPermissionToUpdateAccount && !user.settings.isRestrictedView"
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency" [totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency" [totalValueInBaseCurrency]="totalValueInBaseCurrency"
[transactionCount]="transactionCount" [transactionCount]="transactionCount"

7
apps/client/src/app/pages/accounts/accounts-page.scss

@ -4,11 +4,4 @@
.accounts { .accounts {
overflow-x: auto; overflow-x: auto;
} }
.fab-container {
position: fixed;
right: 2rem;
bottom: 2rem;
z-index: 999;
}
} }

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

@ -36,7 +36,9 @@
type="number" type="number"
(keydown.enter)="$event.stopPropagation()" (keydown.enter)="$event.stopPropagation()"
/> />
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span> <span class="ml-2" matTextSuffix
>{{ accountForm.controls['currency'].value }}</span
>
</mat-form-field> </mat-form-field>
</div> </div>
<div [ngClass]="{ 'd-none': platforms?.length < 1 }"> <div [ngClass]="{ 'd-none': platforms?.length < 1 }">

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

@ -1,13 +1,13 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component'; import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';

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

7
apps/client/src/app/pages/portfolio/activities/activities-page.scss

@ -1,10 +1,3 @@
:host { :host {
display: block; display: block;
.fab-container {
position: fixed;
right: 2rem;
bottom: 2rem;
z-index: 999;
}
} }

15
apps/client/src/app/services/admin.service.ts

@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { UpdateBulkMarketDataDto } from '@ghostfolio/api/app/admin/update-bulk-market-data.dto';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto'; import { CreatePlatformDto } from '@ghostfolio/api/app/platform/create-platform.dto';
import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto'; import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.dto';
@ -214,6 +215,20 @@ export class AdminService {
); );
} }
public postMarketData({
dataSource,
marketData,
symbol
}: {
dataSource: DataSource;
marketData: UpdateBulkMarketDataDto;
symbol: string;
}) {
const url = `/api/v1/admin/market-data/${dataSource}/${symbol}`;
return this.http.post<MarketData>(url, marketData);
}
public postPlatform(aPlatform: CreatePlatformDto) { public postPlatform(aPlatform: CreatePlatformDto) {
return this.http.post<Platform>(`/api/v1/platform`, aPlatform); return this.http.post<Platform>(`/api/v1/platform`, aPlatform);
} }

13
apps/client/src/app/services/data.service.ts

@ -2,6 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
@ -505,6 +506,18 @@ export class DataService {
}); });
} }
public transferAccountBalance({
accountIdFrom,
accountIdTo,
balance
}: TransferBalanceDto) {
return this.http.post('/api/v1/account/transfer-balance', {
accountIdFrom,
accountIdTo,
balance
});
}
public updateInfo() { public updateInfo() {
this.http.get<InfoItem>('/api/v1/info').subscribe((info) => { this.http.get<InfoItem>('/api/v1/info').subscribe((info) => {
const utmSource = <'ios' | 'trusted-web-activity'>( const utmSource = <'ios' | 'trusted-web-activity'>(

4
apps/client/src/app/services/import-activities.service.ts

@ -337,6 +337,10 @@ export class ImportActivitiesService {
return Type.BUY; return Type.BUY;
case 'dividend': case 'dividend':
return Type.DIVIDEND; return Type.DIVIDEND;
case 'fee':
return Type.FEE;
case 'interest':
return Type.INTEREST;
case 'item': case 'item':
return Type.ITEM; return Type.ITEM;
case 'liability': case 'liability':

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

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

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

22
apps/client/src/styles.scss

@ -462,6 +462,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-vertical,
.mat-stepper-horizontal { .mat-stepper-horizontal {
background: transparent !important; background: transparent !important;
@ -491,6 +500,13 @@ ngx-skeleton-loader {
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
&:not(.has-tabs) { &:not(.has-tabs) {
@media (min-width: 576px) { @media (min-width: 576px) {
padding: 2rem 0; padding: 2rem 0;
@ -502,6 +518,12 @@ ngx-skeleton-loader {
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
padding-bottom: constant(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom);
.fab-container {
@media (max-width: 575.98px) {
bottom: 5rem;
}
}
.mat-mdc-tab-nav-bar { .mat-mdc-tab-nav-bar {
--mat-tab-header-active-focus-indicator-color: transparent; --mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent; --mat-tab-header-active-hover-indicator-color: transparent;

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

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

1
libs/common/src/lib/interfaces/benchmark.interface.ts

@ -5,6 +5,7 @@ export interface Benchmark {
name: EnhancedSymbolProfile['name']; name: EnhancedSymbolProfile['name'];
performances: { performances: {
allTimeHigh: { allTimeHigh: {
date: Date;
performancePercent: number; performancePercent: number;
}; };
}; };

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

@ -33,6 +33,7 @@ import type { PortfolioReport } from './portfolio-report.interface';
import type { PortfolioSummary } from './portfolio-summary.interface'; import type { PortfolioSummary } from './portfolio-summary.interface';
import type { Position } from './position.interface'; import type { Position } from './position.interface';
import type { Product } from './product'; import type { Product } from './product';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { ResponseError } from './responses/errors.interface'; import type { ResponseError } from './responses/errors.interface';
import type { ImportResponse } from './responses/import-response.interface'; import type { ImportResponse } from './responses/import-response.interface';
@ -49,6 +50,7 @@ import type { User } from './user.interface';
export { export {
Access, Access,
AccountBalancesResponse,
Accounts, Accounts,
AdminData, AdminData,
AdminJobs, AdminJobs,

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

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

5
libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts

@ -0,0 +1,5 @@
import { AccountBalance } from '@prisma/client';
export interface AccountBalancesResponse {
balances: Pick<AccountBalance, 'date' | 'id' | 'value'>[];
}

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

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

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

@ -1,12 +1,16 @@
<a <a
#link #link
class="d-block px-2 py-1 text-truncate" class="d-block line-height-1 px-2 py-1 text-truncate"
[queryParams]="{ [queryParams]="queryParams"
dataSource: holding?.dataSource, [routerLink]="routerLink"
positionDetailDialog: true,
symbol: holding?.symbol
}"
[routerLink]="['/portfolio', 'holdings']"
(click)="onClick()" (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 { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { AssistantListItemComponent } from './assistant-list-item.component'; import { AssistantListItemComponent } from './assistant-list-item.component';
@NgModule({ @NgModule({
declarations: [AssistantListItemComponent], declarations: [AssistantListItemComponent],
exports: [AssistantListItemComponent], exports: [AssistantListItemComponent],
imports: [CommonModule, RouterModule] imports: [CommonModule, GfSymbolModule, RouterModule]
}) })
export class GfAssistantListItemModule {} export class GfAssistantListItemModule {}

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

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

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

@ -45,8 +45,9 @@
<div> <div>
<div class="h6 mb-1 px-2" i18n>Holdings</div> <div class="h6 mb-1 px-2" i18n>Holdings</div>
<gf-assistant-list-item <gf-assistant-list-item
*ngFor="let holding of searchResults?.holdings" *ngFor="let searchResultItem of searchResults?.holdings"
[holding]="holding" mode="holding"
[item]="searchResultItem"
(clicked)="onCloseAssistant()" (clicked)="onCloseAssistant()"
/> />
<ng-container *ngIf="searchResults?.holdings?.length === 0"> <ng-container *ngIf="searchResults?.holdings?.length === 0">
@ -62,5 +63,26 @@
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div> <div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container> </ng-container>
</div> </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>
</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 { export interface ISearchResults {
holdings: Position[]; assetProfiles: ISearchResultItem[];
holdings: ISearchResultItem[];
} }

27
libs/ui/src/lib/benchmark/benchmark.component.html

@ -6,14 +6,33 @@
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="date">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-2 text-right"
mat-header-cell
>
<ng-container i18n>Last All Time High</ng-container>
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-2" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isDate]="true"
[locale]="locale"
[value]="element?.performances?.allTimeHigh?.date"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="change"> <ng-container matColumnDef="change">
<th *matHeaderCellDef class="text-right" mat-header-cell> <th *matHeaderCellDef class="px-2 text-right" mat-header-cell>
<span class="d-none d-sm-block text-nowrap" i18n <span class="d-none d-sm-block text-nowrap" i18n
>Change from All Time High</span >Change from All Time High</span
> >
<span class="d-block d-sm-none text-nowrap" i18n>from ATH</span> <span class="d-block d-sm-none text-nowrap" i18n>from ATH</span>
</th> </th>
<td *matCellDef="let element" class="text-right" mat-cell> <td *matCellDef="let element" class="px-2 text-right" mat-cell>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
size="medium" size="medium"
@ -25,9 +44,7 @@
'text-success': 'text-success':
element?.performances?.allTimeHigh?.performancePercent > 0 element?.performances?.allTimeHigh?.performancePercent > 0
}" }"
[value]=" [value]="element?.performances?.allTimeHigh?.performancePercent"
element?.performances?.allTimeHigh?.performancePercent ?? undefined
"
></gf-value> ></gf-value>
</td> </td>
</ng-container> </ng-container>

2
libs/ui/src/lib/benchmark/benchmark.component.ts

@ -18,7 +18,7 @@ export class BenchmarkComponent implements OnChanges {
@Input() benchmarks: Benchmark[]; @Input() benchmarks: Benchmark[];
@Input() locale: string; @Input() locale: string;
public displayedColumns = ['name', 'change', 'marketCondition']; public displayedColumns = ['name', 'date', 'change', 'marketCondition'];
public resolveMarketCondition = resolveMarketCondition; public resolveMarketCondition = resolveMarketCondition;
public constructor() {} public constructor() {}

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

@ -1,4 +1,8 @@
<span class="align-items-center d-flex" <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 }"
[ngStyle]="{ 'background-color': color }"
></span>
<span *ngIf="showLabel" class="label">{{ label ?? 'Ghostfolio' }}</span></span <span *ngIf="showLabel" class="label">{{ label ?? 'Ghostfolio' }}</span></span
> >

1
libs/ui/src/lib/logo/logo.component.ts

@ -13,6 +13,7 @@ import {
}) })
export class LogoComponent { export class LogoComponent {
@HostBinding('class') @Input() size: 'large' | 'medium' = 'medium'; @HostBinding('class') @Input() size: 'large' | 'medium' = 'medium';
@Input() color: string;
@Input() label: string; @Input() label: string;
@Input() showLabel = true; @Input() showLabel = true;

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

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

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

@ -0,0 +1,25 @@
<a
class="card-item d-flex flex-column justify-content-between p-4"
[ngClass]="{ premium: name === 'Premium' }"
[routerLink]="routerLinkPricing"
>
<div class="d-flex justify-content-end">
<gf-logo
color="rgba(255, 255, 255, 1)"
size="large"
[showLabel]="false"
></gf-logo>
</div>
<div class="d-flex justify-content-between">
<div>
<div class="card-item-heading mb-1 text-muted" i18n>Membership</div>
<div class="card-item-name line-height-1 text-truncate">{{ name }}</div>
</div>
<div>
<div class="card-item-heading mb-1 text-muted" i18n>Valid until</div>
<div class="card-item-name line-height-1 text-truncate">
{{ expiresAt }}
</div>
</div>
</div>
</a>

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

@ -0,0 +1,24 @@
:host {
display: block;
max-width: 25rem;
.card-item {
aspect-ratio: 1.586;
background-color: #343a40 !important;
border-radius: 1rem;
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.3);
&.premium {
background-color: #1d2124 !important;
}
.card-item-heading {
font-size: 13px;
}
.card-item-name {
color: rgba(var(--light-primary-text));
font-size: 18px;
}
}
}

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 {}

12
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.10.0", "version": "2.13.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -81,9 +81,9 @@
"@nestjs/platform-express": "10.1.3", "@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2", "@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0", "@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.3.1", "@prisma/client": "5.4.2",
"@simplewebauthn/browser": "5.2.1", "@simplewebauthn/browser": "8.3.1",
"@simplewebauthn/server": "5.2.1", "@simplewebauthn/server": "8.3.2",
"@stripe/stripe-js": "1.47.0", "@stripe/stripe-js": "1.47.0",
"alphavantage": "2.2.0", "alphavantage": "2.2.0",
"big.js": "6.2.1", "big.js": "6.2.1",
@ -122,7 +122,7 @@
"passport": "0.6.0", "passport": "0.6.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0", "passport-jwt": "4.0.0",
"prisma": "5.3.1", "prisma": "5.4.2",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
"stripe": "11.12.0", "stripe": "11.12.0",
@ -157,7 +157,7 @@
"@nx/web": "16.7.4", "@nx/web": "16.7.4",
"@nx/workspace": "16.7.4", "@nx/workspace": "16.7.4",
"@schematics/angular": "16.2.0", "@schematics/angular": "16.2.0",
"@simplewebauthn/typescript-types": "5.2.1", "@simplewebauthn/typescript-types": "8.0.0",
"@storybook/addon-essentials": "7.3.2", "@storybook/addon-essentials": "7.3.2",
"@storybook/angular": "7.3.2", "@storybook/angular": "7.3.2",
"@storybook/core-server": "7.3.2", "@storybook/core-server": "7.3.2",

11
test/import/ok.csv

@ -1,5 +1,6 @@
Date,Code,Currency,Price,Quantity,Action,Fee,Note Date,Code,DataSource,Currency,Price,Quantity,Action,Fee,Note
16-09-2021,MSFT,USD,298.580,5,buy,19.00,My first order 🤓 01-09-2021,Account Opening Fee,MANUAL,USD,0,0,fee,49,
17/11/2021,MSFT,USD,0.62,5,dividend,0.00 16-09-2021,MSFT,YAHOO,USD,298.580,5,buy,19.00,My first order 🤓
01.01.2022,Penthouse Apartment,USD,500000.0,1,item,0.00 17/11/2021,MSFT,YAHOO,USD,0.62,5,dividend,0.00
20500606,MSFT,USD,0.00,0,buy,0.00 01.01.2022,Penthouse Apartment,MANUAL,USD,500000.0,1,item,0.00
20500606,MSFT,YAHOO,USD,0.00,0,buy,0.00

Can't render this file because it has a wrong number of fields in line 3.

279
yarn.lock

@ -1819,6 +1819,36 @@
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f"
integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg== integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==
"@cbor-extract/cbor-extract-darwin-arm64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.1.1.tgz#5721f6dd3feae0b96d23122853ce977e0671b7a6"
integrity sha512-blVBy5MXz6m36Vx0DfLd7PChOQKEs8lK2bD1WJn/vVgG4FXZiZmZb2GECHFvVPA5T7OnODd9xZiL3nMCv6QUhA==
"@cbor-extract/cbor-extract-darwin-x64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.1.1.tgz#c25e7d0133950d87d101d7b3afafea8d50d83f5f"
integrity sha512-h6KFOzqk8jXTvkOftyRIWGrd7sKQzQv2jVdTL9nKSf3D2drCvQB/LHUxAOpPXo3pv2clDtKs3xnHalpEh3rDsw==
"@cbor-extract/cbor-extract-linux-arm64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.1.1.tgz#48f78e7d8f0fcc84ed074b6bfa6d15dd83187c63"
integrity sha512-SxAaRcYf8S0QHaMc7gvRSiTSr7nUYMqbUdErBEu+HYA4Q6UNydx1VwFE68hGcp1qvxcy9yT5U7gA+a5XikfwSQ==
"@cbor-extract/cbor-extract-linux-arm@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.1.1.tgz#7507d346389cb682e44fab8fae9534edd52e2e41"
integrity sha512-ds0uikdcIGUjPyraV4oJqyVE5gl/qYBpa/Wnh6l6xLE2lj/hwnjT2XcZCChdXwW/YFZ1LUHs6waoYN8PmK0nKQ==
"@cbor-extract/cbor-extract-linux-x64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.1.1.tgz#b7c1d2be61c58ec18d58afbad52411ded63cd4cd"
integrity sha512-GVK+8fNIE9lJQHAlhOROYiI0Yd4bAZ4u++C2ZjlkS3YmO6hi+FUxe6Dqm+OKWTcMpL/l71N6CQAmaRcb4zyJuA==
"@cbor-extract/cbor-extract-win32-x64@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.1.1.tgz#21b11a1a3f18c3e7d62fd5f87438b7ed2c64c1f7"
integrity sha512-2Niq1C41dCRIDeD8LddiH+mxGlO7HJ612Ll3D/E73ZWBmycued+8ghTr/Ho3CMOWPUEr08XtyBMVXAjqF+TcKw==
"@codewithdan/observable-store@2.2.15": "@codewithdan/observable-store@2.2.15":
version "2.2.15" version "2.2.15"
resolved "https://registry.yarnpkg.com/@codewithdan/observable-store/-/observable-store-2.2.15.tgz#6d27e0988e182853def59a714b712f4389e558d2" resolved "https://registry.yarnpkg.com/@codewithdan/observable-store/-/observable-store-2.2.15.tgz#6d27e0988e182853def59a714b712f4389e558d2"
@ -2540,6 +2570,11 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.1.tgz#1a5b1959a528e374e8037c4396c3e825d6cf4a83" resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.1.1.tgz#1a5b1959a528e374e8037c4396c3e825d6cf4a83"
integrity sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw== integrity sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw==
"@hexagon/base64@^1.1.27":
version "1.1.28"
resolved "https://registry.yarnpkg.com/@hexagon/base64/-/base64-1.1.28.tgz#7d306a97f1423829be5b27c9d388fe50e3099d48"
integrity sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==
"@humanwhocodes/config-array@^0.11.8": "@humanwhocodes/config-array@^0.11.8":
version "0.11.10" version "0.11.10"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2"
@ -4252,7 +4287,7 @@
node-addon-api "^3.2.1" node-addon-api "^3.2.1"
node-gyp-build "^4.3.0" node-gyp-build "^4.3.0"
"@peculiar/asn1-android@^2.1.7": "@peculiar/asn1-android@^2.3.6":
version "2.3.6" version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.3.6.tgz#20363c23bc5b9a91f7ffd80d7c3842dccff8c20b" resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.3.6.tgz#20363c23bc5b9a91f7ffd80d7c3842dccff8c20b"
integrity sha512-zkYh4DsiRhiNfg6tWaUuRc+huwlb9XJbmeZLrjTz9v76UK1Ehq3EnfJFED6P3sdznW/nqWe46LoM9JrqxcD58g== integrity sha512-zkYh4DsiRhiNfg6tWaUuRc+huwlb9XJbmeZLrjTz9v76UK1Ehq3EnfJFED6P3sdznW/nqWe46LoM9JrqxcD58g==
@ -4261,7 +4296,27 @@
asn1js "^3.0.5" asn1js "^3.0.5"
tslib "^2.4.0" tslib "^2.4.0"
"@peculiar/asn1-schema@^2.1.7", "@peculiar/asn1-schema@^2.3.6": "@peculiar/asn1-ecc@^2.3.6":
version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-ecc/-/asn1-ecc-2.3.6.tgz#f155f33f5c61df463d9b33b911d25578a19694b7"
integrity sha512-Hu1xzMJQWv8/GvzOiinaE6XiD1/kEhq2C/V89UEoWeZ2fLUcGNIvMxOr/pMyL0OmpRWj/mhCTXOZp4PP+a0aTg==
dependencies:
"@peculiar/asn1-schema" "^2.3.6"
"@peculiar/asn1-x509" "^2.3.6"
asn1js "^3.0.5"
tslib "^2.4.0"
"@peculiar/asn1-rsa@^2.3.6":
version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-rsa/-/asn1-rsa-2.3.6.tgz#e2af2c52a914c60f33853a86a48905ec555b29c2"
integrity sha512-DswjJyAXZnvESuImGNTvbNKvh1XApBVqU+r3UmrFFTAI23gv62byl0f5OFKWTNhCf66WQrd3sklpsCZc/4+jwA==
dependencies:
"@peculiar/asn1-schema" "^2.3.6"
"@peculiar/asn1-x509" "^2.3.6"
asn1js "^3.0.5"
tslib "^2.4.0"
"@peculiar/asn1-schema@^2.3.6":
version "2.3.6" version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz#3dd3c2ade7f702a9a94dfb395c192f5fa5d6b922" resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.3.6.tgz#3dd3c2ade7f702a9a94dfb395c192f5fa5d6b922"
integrity sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA== integrity sha512-izNRxPoaeJeg/AyH8hER6s+H7p4itk+03QCa4sbxI3lNdseQYCuxzgsuNK8bTXChtLTjpJz6NmXKA73qLa3rCA==
@ -4270,7 +4325,7 @@
pvtsutils "^1.3.2" pvtsutils "^1.3.2"
tslib "^2.4.0" tslib "^2.4.0"
"@peculiar/asn1-x509@^2.1.7": "@peculiar/asn1-x509@^2.3.6":
version "2.3.6" version "2.3.6"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.3.6.tgz#e50154a460cdf43da8a41b23ee807a53e0036af0" resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.3.6.tgz#e50154a460cdf43da8a41b23ee807a53e0036af0"
integrity sha512-dRwX31R1lcbIdzbztiMvLNTDoGptxdV7HocNx87LfKU0fEWh7fTWJjx4oV+glETSy6heF/hJHB2J4RGB3vVSYg== integrity sha512-dRwX31R1lcbIdzbztiMvLNTDoGptxdV7HocNx87LfKU0fEWh7fTWJjx4oV+glETSy6heF/hJHB2J4RGB3vVSYg==
@ -4293,22 +4348,22 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@prisma/client@5.3.1": "@prisma/client@5.4.2":
version "5.3.1" version "5.4.2"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.3.1.tgz#fc7fc2d91e814cc4fe18a4bc5e78bf851c26985e" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-5.4.2.tgz#786f9c1d8f06d955933004ac638d14da4bf14025"
integrity sha512-ArOKjHwdFZIe1cGU56oIfy7wRuTn0FfZjGuU/AjgEBOQh+4rDkB6nF+AGHP8KaVpkBIiHGPQh3IpwQ3xDMdO0Q== integrity sha512-2xsPaz4EaMKj1WS9iW6MlPhmbqtBsXAOeVttSePp8vTFTtvzh2hZbDgswwBdSCgPzmmwF+tLB259QzggvCmJqA==
dependencies: dependencies:
"@prisma/engines-version" "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59" "@prisma/engines-version" "5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574"
"@prisma/engines-version@5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59": "@prisma/engines-version@5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574":
version "5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59" version "5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.3.1-2.61e140623197a131c2a6189271ffee05a7aa9a59.tgz#7eb6f5c6b7628b8b39df55c903f411528a6f761c" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-5.4.1-2.ac9d7041ed77bcc8a8dbd2ab6616b39013829574.tgz#ff14f2926890edee47e8f1d08df7b4f392ee34bf"
integrity sha512-y5qbUi3ql2Xg7XraqcXEdMHh0MocBfnBzDn5GbV1xk23S3Mq8MGs+VjacTNiBh3dtEdUERCrUUG7Z3QaJ+h79w== integrity sha512-wvupDL4AA1vf4TQNANg7kR7y98ITqPsk6aacfBxZKtrJKRIsWjURHkZCGcQliHdqCiW/hGreO6d6ZuSv9MhdAA==
"@prisma/engines@5.3.1": "@prisma/engines@5.4.2":
version "5.3.1" version "5.4.2"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.3.1.tgz#53cc72a5ed176dc27d22305fe5569c64cc78b381" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-5.4.2.tgz#ba2b7faeb227c76e423e88f962afe6a031319f3f"
integrity sha512-6QkILNyfeeN67BNEPEtkgh3Xo2tm6D7V+UhrkBbRHqKw9CTaz/vvTP/ROwYSP/3JT2MtIutZm/EnhxUiuOPVDA== integrity sha512-fqeucJ3LH0e1eyFdT0zRx+oETLancu5+n4lhiYECyEz6H2RDskPJHJYHkVc0LhkU4Uv7fuEnppKU3nVKNzMh8g==
"@radix-ui/number@1.0.1": "@radix-ui/number@1.0.1":
version "1.0.1" version "1.0.1"
@ -4638,38 +4693,32 @@
"@sigstore/protobuf-specs" "^0.1.0" "@sigstore/protobuf-specs" "^0.1.0"
tuf-js "^1.1.7" tuf-js "^1.1.7"
"@simplewebauthn/browser@5.2.1": "@simplewebauthn/browser@8.3.1":
version "5.2.1" version "8.3.1"
resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-5.2.1.tgz#569252a9f235a99aae90c4d1cc6c441f42637b8e" resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-8.3.1.tgz#f5c1aed6313d61944a9e13f16ae4495750bddf93"
integrity sha512-TxL3OPHJf57hmnfQoF3zRIQWEdsJLxrA9NcGdRK0sB/h3jd13kpGQonBtMnj4YBQnWTtRDZ804wlpI9IEMaJ9g== integrity sha512-bMW7oOkxX4ydRAkkPtJ1do2k9yOoIGc/hZYebcuEOVdJoC6wwVpu97mYY7Mz8B9hLlcaR5WFgBsLl5tSJVzm8A==
dependencies:
"@simplewebauthn/server@5.2.1": "@simplewebauthn/typescript-types" "^8.0.0"
version "5.2.1"
resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-5.2.1.tgz#49038d2951ad2ac065bdf8342fdb13f78ee4df1c"
integrity sha512-+CQ8oJf9Io8y4ReYLagX5JG9ShntIkdeCPkMoyHLBSRPlNY0N/Yv3Iun4YPQ8d4LJUU9f8S1eD5bibIEMjWDRg==
dependencies:
"@peculiar/asn1-android" "^2.1.7"
"@peculiar/asn1-schema" "^2.1.7"
"@peculiar/asn1-x509" "^2.1.7"
"@simplewebauthn/typescript-types" "^5.2.1"
base64url "^3.0.1"
cbor "^5.1.0"
debug "^4.3.2"
elliptic "^6.5.3"
jsrsasign "^10.4.0"
jwk-to-pem "^2.0.4"
node-fetch "^2.6.0"
node-rsa "^1.1.1"
"@simplewebauthn/typescript-types@5.2.1": "@simplewebauthn/server@8.3.2":
version "5.2.1" version "8.3.2"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-5.2.1.tgz#a8229ce4f71be7edafe3bfdce062b332ef494f0d" resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-8.3.2.tgz#dfdbe7af4c1258e786c4a0b1c83c54743ba7568c"
integrity sha512-t/NzbjaD0zu4ivUmiof2cPA8X5LHhFX+DflBBl71/dzEhl15qepDI2rxWdjB+Hc0FfOT1fBQnb1uP19fPcDUiA== integrity sha512-ceo8t5gdO5W/JOePQWPDH+rAd8tO6QNalLU56rc9ItdzaTjk+qcYwQg/BKXDDg6117P3HKrRBkZwBrMJl4dOdA==
dependencies:
"@hexagon/base64" "^1.1.27"
"@peculiar/asn1-android" "^2.3.6"
"@peculiar/asn1-ecc" "^2.3.6"
"@peculiar/asn1-rsa" "^2.3.6"
"@peculiar/asn1-schema" "^2.3.6"
"@peculiar/asn1-x509" "^2.3.6"
"@simplewebauthn/typescript-types" "^8.0.0"
cbor-x "^1.5.2"
cross-fetch "^4.0.0"
"@simplewebauthn/typescript-types@^5.2.1": "@simplewebauthn/typescript-types@8.0.0", "@simplewebauthn/typescript-types@^8.0.0":
version "5.4.0" version "8.0.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-5.4.0.tgz#533b28e7cabcc092396ecd07bbb953b71e7696b6" resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-8.0.0.tgz#1698a7228aba880c5c1deba1f13a4f9fd8851cb3"
integrity sha512-LeJq6Jx+o7D6iIlCy8CH5jCjwVcUvAReEo66VcF3nysfc/yKW5yCAPLSRmPITF4CRZTfnVPxUBUcveUQL6aBMA== integrity sha512-d7Izb2H+LZJteXMkS8DmpAarD6mZdpIOu/av/yH4/u/3Pd6DKFLyBM3j8BMmUvUqpzvJvHARNrRfQYto58mtTQ==
"@sinclair/typebox@^0.27.8": "@sinclair/typebox@^0.27.8":
version "0.27.8" version "0.27.8"
@ -7122,17 +7171,7 @@ arrify@^2.0.0:
resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
asn1.js@^5.3.0: asn1@~0.2.3:
version "5.4.1"
resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
dependencies:
bn.js "^4.0.0"
inherits "^2.0.1"
minimalistic-assert "^1.0.0"
safer-buffer "^2.1.0"
asn1@^0.2.4, asn1@~0.2.3:
version "0.2.6" version "0.2.6"
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d"
integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==
@ -7461,7 +7500,7 @@ base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
base64url@3.x.x, base64url@^3.0.1: base64url@3.x.x:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
@ -7527,7 +7566,7 @@ big.js@^5.2.2:
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
bignumber.js@^9.0.0, bignumber.js@^9.0.1: bignumber.js@^9.0.0:
version "9.1.1" version "9.1.1"
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.1.tgz#c4df7dc496bd849d4c9464344c1aa74228b4dac6" resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.1.tgz#c4df7dc496bd849d4c9464344c1aa74228b4dac6"
integrity sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig== integrity sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==
@ -7556,11 +7595,6 @@ bluebird@^3.7.2:
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bn.js@^4.0.0, bn.js@^4.11.9:
version "4.12.0"
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88"
integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==
body-parser@1.20.1: body-parser@1.20.1:
version "1.20.1" version "1.20.1"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668"
@ -7689,11 +7723,6 @@ braces@^3.0.2, braces@~3.0.2:
dependencies: dependencies:
fill-range "^7.0.1" fill-range "^7.0.1"
brorand@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==
browser-assert@^1.2.1: browser-assert@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.yarnpkg.com/browser-assert/-/browser-assert-1.2.1.tgz#9aaa5a2a8c74685c2ae05bfe46efd606f068c200" resolved "https://registry.yarnpkg.com/browser-assert/-/browser-assert-1.2.1.tgz#9aaa5a2a8c74685c2ae05bfe46efd606f068c200"
@ -7953,13 +7982,26 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==
cbor@^5.1.0: cbor-extract@^2.1.1:
version "5.2.0" version "2.1.1"
resolved "https://registry.yarnpkg.com/cbor/-/cbor-5.2.0.tgz#4cca67783ccd6de7b50ab4ed62636712f287a67c" resolved "https://registry.yarnpkg.com/cbor-extract/-/cbor-extract-2.1.1.tgz#f154b31529fdb6b7c70fb3ca448f44eda96a1b42"
integrity sha512-5IMhi9e1QU76ppa5/ajP1BmMWZ2FHkhAhjeVKQ/EFCgYSEaeVaoGtL7cxJskf9oCCk+XjzaIdc3IuU/dbA/o2A== integrity sha512-1UX977+L+zOJHsp0mWFG13GLwO6ucKgSmSW6JTl8B9GUvACvHeIVpFqhU92299Z6PfD09aTXDell5p+lp1rUFA==
dependencies: dependencies:
bignumber.js "^9.0.1" node-gyp-build-optional-packages "5.0.3"
nofilter "^1.0.4" optionalDependencies:
"@cbor-extract/cbor-extract-darwin-arm64" "2.1.1"
"@cbor-extract/cbor-extract-darwin-x64" "2.1.1"
"@cbor-extract/cbor-extract-linux-arm" "2.1.1"
"@cbor-extract/cbor-extract-linux-arm64" "2.1.1"
"@cbor-extract/cbor-extract-linux-x64" "2.1.1"
"@cbor-extract/cbor-extract-win32-x64" "2.1.1"
cbor-x@^1.5.2:
version "1.5.4"
resolved "https://registry.yarnpkg.com/cbor-x/-/cbor-x-1.5.4.tgz#8f0754fa8589cbd7339b613b2b5717d133508e98"
integrity sha512-PVKILDn+Rf6MRhhcyzGXi5eizn1i0i3F8Fe6UMMxXBnWkalq9+C5+VTmlIjAYM4iF2IYF2N+zToqAfYOp+3rfw==
optionalDependencies:
cbor-extract "^2.1.1"
chalk@^1.0.0, chalk@^1.1.3: chalk@^1.0.0, chalk@^1.1.3:
version "1.1.3" version "1.1.3"
@ -8691,6 +8733,13 @@ cross-fetch@^3.0.5:
dependencies: dependencies:
node-fetch "^2.6.12" node-fetch "^2.6.12"
cross-fetch@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983"
integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==
dependencies:
node-fetch "^2.6.12"
cross-spawn@^6.0.0, cross-spawn@^6.0.5: cross-spawn@^6.0.0, cross-spawn@^6.0.5:
version "6.0.5" version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
@ -9741,19 +9790,6 @@ elkjs@^0.8.2:
resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e" resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e"
integrity sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ== integrity sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ==
elliptic@^6.5.3, elliptic@^6.5.4:
version "6.5.4"
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
dependencies:
bn.js "^4.11.9"
brorand "^1.1.0"
hash.js "^1.0.0"
hmac-drbg "^1.0.1"
inherits "^2.0.4"
minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^1.0.1"
emittery@^0.13.1: emittery@^0.13.1:
version "0.13.1" version "0.13.1"
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
@ -11673,14 +11709,6 @@ has@^1.0.3:
dependencies: dependencies:
function-bind "^1.1.1" function-bind "^1.1.1"
hash.js@^1.0.0, hash.js@^1.0.3:
version "1.1.7"
resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42"
integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==
dependencies:
inherits "^2.0.3"
minimalistic-assert "^1.0.1"
hdr-histogram-js@^2.0.1: hdr-histogram-js@^2.0.1:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz#0b860534655722b6e3f3e7dca7b78867cf43dcb5" resolved "https://registry.yarnpkg.com/hdr-histogram-js/-/hdr-histogram-js-2.0.3.tgz#0b860534655722b6e3f3e7dca7b78867cf43dcb5"
@ -11710,15 +11738,6 @@ helmet@7.0.0:
resolved "https://registry.yarnpkg.com/helmet/-/helmet-7.0.0.tgz#ac3011ba82fa2467f58075afa58a49427ba6212d" resolved "https://registry.yarnpkg.com/helmet/-/helmet-7.0.0.tgz#ac3011ba82fa2467f58075afa58a49427ba6212d"
integrity sha512-MsIgYmdBh460ZZ8cJC81q4XJknjG567wzEmv46WOBblDb6TUd3z8/GhgmsM9pn8g2B80tAJ4m5/d3Bi1KrSUBQ== integrity sha512-MsIgYmdBh460ZZ8cJC81q4XJknjG567wzEmv46WOBblDb6TUd3z8/GhgmsM9pn8g2B80tAJ4m5/d3Bi1KrSUBQ==
hmac-drbg@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==
dependencies:
hash.js "^1.0.3"
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hosted-git-info@^2.1.4: hosted-git-info@^2.1.4:
version "2.8.9" version "2.8.9"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
@ -13490,11 +13509,6 @@ jsprim@^2.0.2:
json-schema "0.4.0" json-schema "0.4.0"
verror "1.10.0" verror "1.10.0"
jsrsasign@^10.4.0:
version "10.8.6"
resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-10.8.6.tgz#ebf7f3c812c6517af84f0d8a10115e0dbfabe145"
integrity sha512-bQmbVtsfbgaKBTWCKiDCPlUPbdlRIK/FzSwT3BzIgZl/cU6TqXu6pZJsCI/dJVrZ9Gir5GC4woqw9shH/v7MBw==
jwa@^1.4.1: jwa@^1.4.1:
version "1.4.1" version "1.4.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
@ -13513,15 +13527,6 @@ jwa@^2.0.0:
ecdsa-sig-formatter "1.0.11" ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1" safe-buffer "^5.0.1"
jwk-to-pem@^2.0.4:
version "2.0.5"
resolved "https://registry.yarnpkg.com/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz#151310bcfbcf731adc5ad9f379cbc8b395742906"
integrity sha512-L90jwellhO8jRKYwbssU9ifaMVqajzj3fpRjDKcsDzrslU9syRbFqfkXtT4B89HYAap+xsxNcxgBSB09ig+a7A==
dependencies:
asn1.js "^5.3.0"
elliptic "^6.5.4"
safe-buffer "^5.0.1"
jws@^3.2.2: jws@^3.2.2:
version "3.2.2" version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
@ -14226,16 +14231,11 @@ mini-css-extract-plugin@~2.4.7:
dependencies: dependencies:
schema-utils "^4.0.0" schema-utils "^4.0.0"
minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: minimalistic-assert@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
minimalistic-crypto-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==
minimatch@3.0.5: minimatch@3.0.5:
version "3.0.5" version "3.0.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.5.tgz#4da8f1290ee0f0f8e83d60ca69f8f134068604a3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.5.tgz#4da8f1290ee0f0f8e83d60ca69f8f134068604a3"
@ -14600,7 +14600,7 @@ node-fetch-native@^1.0.2:
resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.2.0.tgz#13ec6df98f33168958dbfb6945f10aedf42e7ea8" resolved "https://registry.yarnpkg.com/node-fetch-native/-/node-fetch-native-1.2.0.tgz#13ec6df98f33168958dbfb6945f10aedf42e7ea8"
integrity sha512-5IAMBTl9p6PaAjYCnMv5FmqIF6GcZnawAVnzaCG0rX2aYZJ4CxEkZNtVPuTRug7fL7wyM5BQYTlAzcyMPi6oTQ== integrity sha512-5IAMBTl9p6PaAjYCnMv5FmqIF6GcZnawAVnzaCG0rX2aYZJ4CxEkZNtVPuTRug7fL7wyM5BQYTlAzcyMPi6oTQ==
node-fetch@^2.0.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7: node-fetch@^2.0.0, node-fetch@^2.6.1, node-fetch@^2.6.12, node-fetch@^2.6.7:
version "2.6.12" version "2.6.12"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba"
integrity sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g== integrity sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==
@ -14612,6 +14612,11 @@ node-forge@^1, node-forge@^1.3.1:
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3"
integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==
node-gyp-build-optional-packages@5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.3.tgz#92a89d400352c44ad3975010368072b41ad66c17"
integrity sha512-k75jcVzk5wnnc/FMxsf4udAoTEUv2jY3ycfdSd3yWu6Cnd1oee6/CfZJApyscA4FJOmdoixWwiwOyf16RzD5JA==
node-gyp-build-optional-packages@5.0.7: node-gyp-build-optional-packages@5.0.7:
version "5.0.7" version "5.0.7"
resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz#5d2632bbde0ab2f6e22f1bbac2199b07244ae0b3" resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz#5d2632bbde0ab2f6e22f1bbac2199b07244ae0b3"
@ -14654,18 +14659,6 @@ node-releases@^2.0.12:
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.13.tgz#d5ed1627c23e3461e819b02e57b75e4899b1c81d"
integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ== integrity sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==
node-rsa@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/node-rsa/-/node-rsa-1.1.1.tgz#efd9ad382097782f506153398496f79e4464434d"
integrity sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==
dependencies:
asn1 "^0.2.4"
nofilter@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-1.0.4.tgz#78d6f4b6a613e7ced8b015cec534625f7667006e"
integrity sha512-N8lidFp+fCz+TD51+haYdbDGrcBWwuHX40F5+z0qkUjMJ5Tp+rdSuAkMJ9N9eoolDlEVTf6u5icM+cNKkKW2mA==
non-layered-tidy-tree-layout@^2.0.2: non-layered-tidy-tree-layout@^2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz#57d35d13c356643fc296a55fb11ac15e74da7804" resolved "https://registry.yarnpkg.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz#57d35d13c356643fc296a55fb11ac15e74da7804"
@ -15918,12 +15911,12 @@ pretty-hrtime@^1.0.3:
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A== integrity sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==
prisma@5.3.1: prisma@5.4.2:
version "5.3.1" version "5.4.2"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.3.1.tgz#a0932c1c1a5ed4ff449d064b193d9c7e94e8bf77" resolved "https://registry.yarnpkg.com/prisma/-/prisma-5.4.2.tgz#7eac9276439ec7073ec697c6c0dfa259d96e955e"
integrity sha512-Wp2msQIlMPHe+5k5Od6xnsI/WNG7UJGgFUJgqv/ygc7kOECZapcSz/iU4NIEzISs3H1W9sFLjAPbg/gOqqtB7A== integrity sha512-GDMZwZy7mysB2oXU+angQqJ90iaPFdD0rHaZNkn+dio5NRkGLmMqmXs31//tg/qXT3iB0cTQwnGGQNuirhSTZg==
dependencies: dependencies:
"@prisma/engines" "5.3.1" "@prisma/engines" "5.4.2"
prismjs@^1.28.0: prismjs@^1.28.0:
version "1.29.0" version "1.29.0"

Loading…
Cancel
Save