Compare commits

...

56 Commits

Author SHA1 Message Date
bf2c4d1e9e Release 1.176.0 (#1115) 2022-07-30 21:27:35 +02:00
eee1f1c722 Feature/add page titles (#1114)
* Add page titles

* Update changelog
2022-07-30 21:26:08 +02:00
9f2a49a1c7 Feature/introduce max number of symbols per data provider request (#1111)
* Introduce maximum number of symbols per request

* Set log level settings

* Update changelog
2022-07-30 15:48:45 +02:00
44058b2d7a Add descriptions (#1113) 2022-07-30 15:46:58 +02:00
23634f3404 Add environment variables (#1112) 2022-07-30 15:21:14 +02:00
f93dab6086 Add FAQ (#1108) 2022-07-29 19:25:41 +02:00
207859cc22 Release 1.175.0 (#1107) 2022-07-29 18:34:05 +02:00
77181aaaff Feature/setup faq page (#1106)
* Set up FAQ page

* Update changelog
2022-07-29 18:32:26 +02:00
412039badf Bugfix/show symbols of activities in account detail dialog (#1105)
* Show symbols

* Update changelog
2022-07-29 17:04:30 +02:00
7619442895 Feature/add savings rate to investment timeline (#1104)
* Add line for savings rate

* Update changelog
2022-07-29 17:03:23 +02:00
61ecd66e0f Release 1.174.0 (#1098) 2022-07-27 21:09:03 +02:00
81217b35ef Feature/add comment to activity (#1097)
* Add comment to activity

* Update changelog
2022-07-27 21:07:27 +02:00
678f1f0051 Release 1.173.0 (#1095) 2022-07-23 20:38:53 +02:00
71c7e37b5a Bugfix/fix currency inconsistency with usx (#1094)
* Support USX

* Update changelog
2022-07-23 20:37:26 +02:00
80459371f3 Release 1.172.0 (#1093) 2022-07-23 12:09:38 +02:00
35f1f348a8 Feature/add blog post ghostfolio meets internet identity (#1092)
* Add blog post: Ghostfolio meets Internet Identity

* Update changelog
2022-07-23 12:06:49 +02:00
0bb0b12991 Release 1.171.0 (#1091) 2022-07-22 19:57:04 +02:00
d887de50d2 Feature/setup internet identity (#1080)
* Setup Internet Identity as social login provider

* Update changelog
2022-07-22 19:55:33 +02:00
2571e5b8c0 Feature/improve empty states (#1090)
* Improve empty states

* Update changelog
2022-07-22 19:33:06 +02:00
e444d717e5 Add tests for investments by month (#1089)
* Fix investments by month

* Add tests for investments and investments by month

* Update changelog
2022-07-22 19:00:36 +02:00
1866e26c1d Fix distorted tooltip (#1088)
* Fix distorted tooltip

* Update changelog
2022-07-21 20:36:16 +02:00
9923074e04 Add script to open prisma studio with prod env variables (#1087) 2022-07-20 09:16:02 +02:00
c367e61b85 Release 1.170.0 (#1086) 2022-07-19 20:30:59 +02:00
364f1ad9b9 Feature/add support for ust usd (#1085)
* Add UST

* Update changelog
2022-07-19 20:29:42 +02:00
2394cbd6fe Feature/support tags in create and update order dto (#1084)
* Support tags in create or update order

* Update changelog
2022-07-19 20:24:01 +02:00
a74d5cce20 Feature/remove activities import limit for premium users (#1082)
* Remove activities import limit for premium users

* Update changelog
2022-07-17 20:44:28 +02:00
95bcc3f32d Feature/remove alias from user interface (#1083)
* Remove alias from user interface

* Update changelog
2022-07-17 11:05:23 +02:00
e9dbd4a55d Add blog post to resources (#1081) 2022-07-17 10:09:11 +02:00
d440b09dc9 Improve quotes (#1078) 2022-07-15 09:04:44 +02:00
cc16ba5dc8 Release 1.169.0 (#1077) 2022-07-14 16:31:03 +02:00
d10227bc39 Feature/add support for luna2 and songbird cryptocurrencies (#1075)
* Add LUNA2 and SGB1

* Update changelog
2022-07-14 16:28:50 +02:00
4e214c32e8 Feature/update cryptocurrencies.json 20220714 (#1074)
* Update cryptocurrencies.json

* Update changelog
2022-07-14 16:27:32 +02:00
49e2862e03 Feature/add blog post about personal finances (#1073)
* Add blog post

* Update changelog
2022-07-14 16:16:07 +02:00
34e33a2400 Feature/upgrade date fns to version 2.28.0 (#1070)
* Upgrade date-fns

* Update changelog
2022-07-11 19:39:03 +02:00
ec9bc984af Release 1.168.0 (#1071) 2022-07-10 22:25:58 +02:00
2388c494df Feature/handle currency pair inconsistency in yahoo finance service (#1069)
* Handle occasional currency pair inconsistency: GBP=X instead of USDGBP=X

* Update changelog
2022-07-10 22:24:27 +02:00
d71ab10eed Bugfix/fix content height of account detail dialog (#1068)
* Fix height

* Update changelog
2022-07-10 21:44:23 +02:00
0e0592180f Add current month (#1067) 2022-07-10 09:41:48 +02:00
60e2aff488 Extend investment timeline by month (#1066)
* Extend investment timeline grouped by month

* Update changelog
2022-07-09 21:18:05 +02:00
7b5454e7de Release 1.167.0 (#1065) 2022-07-07 21:08:32 +02:00
30835ced88 Bugfix/fix holdings for basic users (#1064)
* Fix holdings for basic users

* Update changelog
2022-07-07 21:06:12 +02:00
8897f32bc5 Clean up modules (#1063) 2022-07-07 07:04:23 +02:00
abaa6b5f27 Feature/improve create account link in live demo (#1061)
* Improve router link

* Update changelog
2022-07-06 17:49:19 +02:00
2060fcaf0b Feature/add markets to public pages (#1062)
* Add Markets to public pages

* Update changelog
2022-07-05 21:45:27 +02:00
fd2408dd62 fix: add git when building docker image (#1052) 2022-07-03 19:57:04 +02:00
31cca024f1 Feature/upgrade ngx markdown to version 14.0.1 (#1055)
* Upgrade ngx-markdown

* Update changelog
2022-07-02 10:58:46 +02:00
b535122945 Make use of demo route (#1060) 2022-07-01 20:07:49 +02:00
5113e4e3ad Release 1.166.0 (#1059) 2022-06-30 21:09:58 +02:00
35e039748f Feature/refactor demo account as route (#1058)
* Refactor demo account as route

* Update changelog
2022-06-30 21:07:35 +02:00
c6b9e0aa5b Feature/upgrade zone.js to version 0.11.6 (#1054)
* Upgrade zone.js

* Update changelog
2022-06-30 19:38:33 +02:00
b250491ca5 Feature/upgrade yahoo finance2 to version 2.3.3 (#1053)
* Upgrade yahoo-finance2 to version 2.3.3

* Update changelog
2022-06-30 08:04:39 +02:00
61e501c659 Feature/fix version of @angular/cli (#1056)
* Fix version

* Update yarn.lock
2022-06-29 21:07:55 +02:00
c0f19d56ec Feature/add account detail dialog (#1047)
* Add account detail dialog

* Update changelog
2022-06-28 21:08:34 +02:00
8e2b235b1f Feature/improve search label (#1048)
* Improve search label

* Update changelog
2022-06-28 13:33:59 +02:00
c3407e9b34 Feature/upgrade prisma to version 3.15.2 (#1046)
* Upgrade prisma to version 3.15.2

* Update changelog
2022-06-25 19:04:01 +02:00
74193e4ee2 Feature/upgrade nestjs dependencies to version 8.4.7 (#1045)
* Upgrade nestjs dependencies to version 8.4.7

* Update changelog
2022-06-25 19:03:25 +02:00
212 changed files with 4034 additions and 1825 deletions

3
.gitignore vendored
View File

@ -24,15 +24,16 @@
# misc
/.angular/cache
.env.prod
/.sass-cache
/connect.lock
/coverage
/dist
/libpeerconnection.log
npm-debug.log
yarn-error.log
testem.log
/typings
yarn-error.log
# System Files
.DS_Store

View File

@ -5,6 +5,145 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.176.0 - 30.07.2022
### Added
- Added page titles
### Changed
- Improved the performance of data provider requests by introducing a maximum number of symbols per request (chunk size)
- Changed the log level settings
## 1.175.0 - 29.07.2022
### Added
- Set up a Frequently Asked Questions (FAQ) page
- Added the savings rate to the investment timeline grouped by month
### Fixed
- Added the symbols to the activities in the account detail dialog
## 1.174.0 - 27.07.2022
### Added
- Support a note for activities
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.173.0 - 23.07.2022
### Fixed
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `USX` to `USD`)
## 1.172.0 - 23.07.2022
### Added
- Added a blog post: _Ghostfolio meets Internet Identity_
## 1.171.0 - 22.07.2022
### Added
- Added _Internet Identity_ as a new social login provider
### Changed
- Improved the empty state of the
- _Analysis_ section
- _Holdings_ section
- performance chart on the home page
### Fixed
- Fixed the distorted tooltip in the performance chart on the home page
- Fixed a calculation issue of the current month in the investment timeline grouped by month
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.170.0 - 19.07.2022
### Added
- Added support for the tags in the create or edit transaction dialog
- Added support for the cryptocurrency _TerraUSD_ (`UST-USD`)
### Changed
- Removed the alias from the user interface as a preparation to remove it from the `User` database schema
- Removed the activities import limit for users with a subscription
### Todo
- Rename the environment variable from `MAX_ORDERS_TO_IMPORT` to `MAX_ACTIVITIES_TO_IMPORT`
## 1.169.0 - 14.07.2022
### Added
- Added support for the cryptocurrency _Songbird_ (`SGB1-USD`)
- Added support for the cryptocurrency _Terra 2.0_ (`LUNA2-USD`)
- Added a blog post
### Changed
- Refreshed the cryptocurrencies list to support more coins by default
- Upgraded `date-fns` from version `2.22.1` to `2.28.0`
## 1.168.0 - 10.07.2022
### Added
- Extended the investment timeline grouped by month
### Changed
- Handled an occasional currency pair inconsistency in the _Yahoo Finance_ service (`GBP=X` instead of `USDGBP=X`)
### Fixed
- Fixed the content height of the account detail dialog
## 1.167.0 - 07.07.2022
### Added
- Added _Markets_ to the public pages
### Changed
- Improved the _Create Account_ link in the _Live Demo_
- Upgraded `ngx-markdown` from version `13.0.0` to `14.0.1`
### Fixed
- Fixed an issue in the _Holdings_ section for users without a subscription
## 1.166.0 - 30.06.2022
### Added
- Added an account detail dialog
### Changed
- Improved the label of the (symbol) search
- Refactored the demo account as a route (`/demo`)
- Upgraded `nestjs` from version `8.2.3` to `8.4.7`
- Upgraded `prisma` from version `3.14.0` to `3.15.2`
- Upgraded `yahoo-finance2` from version `2.3.2` to `2.3.3`
- Upgraded `zone.js` from version `0.11.4` to `0.11.6`
## 1.165.0 - 25.06.2022
### Added

View File

@ -12,7 +12,7 @@ COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN apk add --no-cache python3 g++ make openssl
RUN apk add --no-cache python3 g++ make openssl git
RUN yarn install
# See https://github.com/nrwl/nx/issues/6586 for further details

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
<a href="https://ghostfol.io"><strong>Ghostfol.io</strong></a> | <a href="https://ghostfol.io/demo"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/faq"><strong>FAQ</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p>
<p>
<a href="#contributing">
@ -81,6 +81,23 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## Self-hosting
### Supported Environment Variables
| Name | Default Value | Description |
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
| `BASE_CURRENCY` | `USD` | The base currency of the Ghostfolio application. Caution: This cannot be changed later! |
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_HOST` | `localhost` | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | `6379` | The port where _Redis_ is running |
### Run with Docker Compose
#### Prerequisites

View File

@ -115,7 +115,7 @@
}
],
"styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"],
"scripts": ["node_modules/marked/marked.min.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,

View File

@ -7,7 +7,10 @@ import {
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import type {
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -123,13 +126,45 @@ export class AccountController {
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
return this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
public async getAccountById(
@Headers('impersonation-id') impersonationId,
@Param('id') id: string
): Promise<AccountWithValue> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id,
[{ id, type: 'ACCOUNT' }]
);
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value',
'valueInBaseCurrency'
])
};
}
return accountsWithAggregations.accounts[0];
}
@Post()

View File

@ -1,6 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -24,7 +23,6 @@ export class AdminService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
@ -174,7 +172,6 @@ export class AdminService {
_count: {
select: { Account: true, Order: true }
},
alias: true,
Analytics: {
select: {
activityCount: true,
@ -194,7 +191,7 @@ export class AdminService {
});
return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
({ _count, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
@ -206,7 +203,6 @@ export class AdminService {
: undefined;
return {
alias,
createdAt,
engagement,
id,

View File

@ -1,5 +1,6 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { OAuthResponse } from '@ghostfolio/common/interfaces';
import {
Body,
Controller,
@ -31,7 +32,9 @@ export class AuthController {
) {}
@Get('anonymous/:accessToken')
public async accessTokenLogin(@Param('accessToken') accessToken: string) {
public async accessTokenLogin(
@Param('accessToken') accessToken: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateAnonymousLogin(
accessToken
@ -65,6 +68,23 @@ export class AuthController {
}
}
@Get('internet-identity/:principalId')
public async internetIdentityLogin(
@Param('principalId') principalId: string
): Promise<OAuthResponse> {
try {
const authToken = await this.authService.validateInternetIdentityLogin(
principalId
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'))
public async generateRegistrationOptions() {

View File

@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Provider } from '@prisma/client';
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -13,7 +14,7 @@ export class AuthService {
private readonly userService: UserService
) {}
public async validateAnonymousLogin(accessToken: string) {
public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken(
@ -26,7 +27,7 @@ export class AuthService {
});
if (user) {
const jwt: string = this.jwtService.sign({
const jwt = this.jwtService.sign({
id: user.id
});
@ -40,6 +41,33 @@ export class AuthService {
});
}
public async validateInternetIdentityLogin(principalId: string) {
try {
const provider: Provider = 'INTERNET_IDENTITY';
let [user] = await this.userService.users({
where: { provider, thirdPartyId: principalId }
});
if (!user) {
// Create new user if not found
user = await this.userService.createUser({
provider,
thirdPartyId: principalId
});
}
return this.jwtService.sign({
id: user.id
});
} catch (error) {
throw new InternalServerErrorException(
'validateInternetIdentityLogin',
error.message
);
}
}
public async validateOAuthLogin({
provider,
thirdPartyId
@ -57,13 +85,14 @@ export class AuthService {
});
}
const jwt: string = this.jwtService.sign({
return this.jwtService.sign({
id: user.id
});
return jwt;
} catch (err) {
throw new InternalServerErrorException('validateOAuthLogin', err.message);
} catch (error) {
throw new InternalServerErrorException(
'validateOAuthLogin',
error.message
);
}
}
}

View File

@ -3,8 +3,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { BenchmarkService } from './benchmark.service';
@ -16,7 +15,6 @@ export class BenchmarkController {
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {

View File

@ -18,6 +18,7 @@ export class ExportService {
orderBy: { date: 'desc' },
select: {
accountId: true,
comment: true,
date: true,
fee: true,
id: true,
@ -40,6 +41,7 @@ export class ExportService {
activities: activities.map(
({
accountId,
comment,
date,
fee,
id,
@ -50,6 +52,7 @@ export class ExportService {
}) => {
return {
accountId,
comment,
fee,
id,
quantity,

View File

@ -34,8 +34,20 @@ export class ImportController {
);
}
let maxActivitiesToImport = this.configurationService.get(
'MAX_ACTIVITIES_TO_IMPORT'
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Premium'
) {
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
}
try {
return await this.importService.import({
maxActivitiesToImport,
activities: importData.activities,
userId: this.request.user.id
});

View File

@ -17,9 +17,11 @@ export class ImportService {
public async import({
activities,
maxActivitiesToImport,
userId
}: {
activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string;
}): Promise<void> {
for (const activity of activities) {
@ -32,7 +34,11 @@ export class ImportService {
}
}
await this.validateActivities({ activities, userId });
await this.validateActivities({
activities,
maxActivitiesToImport,
userId
});
const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => {
@ -42,6 +48,7 @@ export class ImportService {
for (const {
accountId,
comment,
currency,
dataSource,
date,
@ -52,6 +59,7 @@ export class ImportService {
unitPrice
} of activities) {
await this.orderService.createOrder({
comment,
fee,
quantity,
type,
@ -81,19 +89,15 @@ export class ImportService {
private async validateActivities({
activities,
maxActivitiesToImport,
userId
}: {
activities: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string;
}) {
if (
activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) {
throw new Error(
`Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT'
)} at most)`
);
if (activities?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const existingActivities = await this.orderService.orders({

View File

@ -63,6 +63,8 @@ export class InfoService {
} else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
}
globalPermissions.push(permissions.enableFearAndGreedIndex);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {

View File

@ -1,30 +1,46 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
import { isString } from 'lodash';
export class CreateOrderDto {
@IsString()
@IsOptional()
@IsString()
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
@IsEnum(AssetClass, { each: true })
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
@IsEnum(AssetSubClass, { each: true })
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;
@IsEnum(DataSource, { each: true })
@IsOptional()
@IsEnum(DataSource, { each: true })
dataSource?: DataSource;
@IsISO8601()
@ -39,6 +55,10 @@ export class CreateOrderDto {
@IsString()
symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsEnum(Type, { each: true })
type: Type;

View File

@ -4,6 +4,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -17,6 +18,7 @@ import {
Param,
Post,
Put,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
@ -66,8 +68,36 @@ export class OrderController {
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId
@Headers('impersonation-id') impersonationId,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string
): Promise<Activities> {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
@ -76,6 +106,7 @@ export class OrderController {
const userCurrency = this.request.user.Settings.currency;
let activities = await this.orderService.getOrders({
filters,
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id

View File

@ -16,11 +16,12 @@ import {
DataSource,
Order,
Prisma,
Tag,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash';
import { groupBy, isString } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface';
@ -71,6 +72,7 @@ export class OrderService {
currency?: string;
dataSource?: DataSource;
symbol?: string;
tags?: Tag[];
userId: string;
}
): Promise<Order> {
@ -80,6 +82,8 @@ export class OrderService {
return account.isDefault === true;
});
const tags = data.tags ?? [];
let Account = {
connect: {
id_userId: {
@ -139,9 +143,15 @@ export class OrderService {
delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
if (!data.comment) {
delete data.comment;
}
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.tags;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
@ -150,7 +160,12 @@ export class OrderService {
data: {
...orderData,
Account,
isDraft
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
}
});
}
@ -298,6 +313,7 @@ export class OrderService {
currency?: string;
dataSource?: DataSource;
symbol?: string;
tags?: Tag[];
};
where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> {
@ -305,6 +321,12 @@ export class OrderService {
delete data.Account;
}
if (!data.comment) {
data.comment = null;
}
const tags = data.tags ?? [];
let isDraft = false;
if (data.type === 'ITEM') {
@ -331,11 +353,17 @@ export class OrderService {
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.tags;
return this.prismaService.order.update({
data: {
...data,
isDraft
isDraft,
tags: {
connect: tags.map(({ id }) => {
return { id };
})
}
},
where
});

View File

@ -1,11 +1,20 @@
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Tag,
Type
} from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import {
IsArray,
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
import { isString } from 'lodash';
export class UpdateOrderDto {
@IsOptional()
@ -20,6 +29,13 @@ export class UpdateOrderDto {
@IsOptional()
assetSubClass?: AssetSubClass;
@IsOptional()
@IsString()
@Transform(({ value }: TransformFnParams) =>
isString(value) ? value.trim() : value
)
comment?: string;
@IsString()
currency: string;
@ -41,6 +57,10 @@ export class UpdateOrderDto {
@IsString()
symbol: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsString()
type: Type;

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -91,6 +95,15 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('12.6') }
]);
});
});
});

View File

@ -51,6 +51,10 @@ describe('PortfolioCalculator', () => {
parseDate('2021-11-30')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -80,6 +84,14 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('273.2')
});
expect(investments).toEqual([
{ date: '2021-11-30', investment: new Big('273.2') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: new Big('273.2') }
]);
});
});
});

View File

@ -39,6 +39,10 @@ describe('PortfolioCalculator', () => {
new Date()
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -51,6 +55,10 @@ describe('PortfolioCalculator', () => {
positions: [],
totalInvestment: new Big(0)
});
expect(investments).toEqual([]);
expect(investmentsByMonth).toEqual([]);
});
});
});

View File

@ -62,6 +62,10 @@ describe('PortfolioCalculator', () => {
parseDate('2022-03-07')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByMonth();
spy.mockRestore();
expect(currentPositions).toEqual({
@ -91,6 +95,16 @@ describe('PortfolioCalculator', () => {
],
totalInvestment: new Big('75.80')
});
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: new Big('151.6') },
{ date: '2022-04-01', investment: new Big('-85.73') }
]);
});
});
});

View File

@ -14,8 +14,11 @@ import {
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
max,
min
min,
set
} from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash';
@ -323,6 +326,53 @@ export class PortfolioCalculator {
});
}
public getInvestmentsByMonth(): { date: string; investment: Big }[] {
if (this.orders.length === 0) {
return [];
}
const investments = [];
let currentDate: Date;
let investmentByMonth = new Big(0);
for (const [index, order] of this.orders.entries()) {
if (
isSameMonth(parseDate(order.date), currentDate) &&
isSameYear(parseDate(order.date), currentDate)
) {
// Same month: Add up investments
investmentByMonth = investmentByMonth.plus(
order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
);
} else {
// New month: Store previous month and reset
if (currentDate) {
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
currentDate = parseDate(order.date);
investmentByMonth = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
}
if (index === this.orders.length - 1) {
// Store current month (latest order)
investments.push({
date: format(set(currentDate, { date: 1 }), DATE_FORMAT),
investment: investmentByMonth
});
}
}
return investments;
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string

View File

@ -20,7 +20,12 @@ import {
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
DateRange,
GroupBy,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -190,21 +195,35 @@ export class PortfolioController {
}
}
const isBasicUser =
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic';
let hasDetails = true;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = this.request.user.subscription.type === 'Premium';
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
...portfolioPosition,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
sectors: hasDetails ? portfolioPosition.sectors : []
};
}
return {
accounts,
hasError,
holdings: isBasicUser ? {} : holdings
holdings
};
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string
@Headers('impersonation-id') impersonationId: string,
@Query('groupBy') groupBy?: GroupBy
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
@ -216,9 +235,16 @@ export class PortfolioController {
);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
let investments: InvestmentItem[];
if (groupBy === 'month') {
investments = await this.portfolioService.getInvestments(
impersonationId,
'month'
);
} else {
investments = await this.portfolioService.getInvestments(impersonationId);
}
if (
impersonationId ||
@ -340,8 +366,8 @@ export class PortfolioController {
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.value / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency,
markets: portfolioPosition.markets,
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name,
netPerformancePercent: portfolioPosition.netPerformancePercent,
sectors: hasDetails ? portfolioPosition.sectors : [],

View File

@ -41,6 +41,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import type {
AccountWithValue,
DateRange,
GroupBy,
Market,
OrderWithAccount,
RequestWithUser
@ -50,6 +51,7 @@ import { REQUEST } from '@nestjs/core';
import {
AssetClass,
DataSource,
Prisma,
Tag,
Type as TypeOfOrder
} from '@prisma/client';
@ -63,6 +65,7 @@ import {
max,
parse,
parseISO,
set,
setDayOfYear,
startOfDay,
subDays,
@ -100,14 +103,23 @@ export class PortfolioService {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
public async getAccounts(
aUserId: string,
aFilters?: Filter[]
): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: aUserId };
if (aFilters?.[0].id && aFilters?.[0].type === 'ACCOUNT') {
where.id = aFilters[0].id;
}
const [accounts, details] = await Promise.all([
this.accountService.accounts({
where,
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: aUserId }
orderBy: { name: 'asc' }
}),
this.getDetails(aUserId, aUserId)
this.getDetails(aUserId, aUserId, undefined, aFilters)
]);
const userCurrency = this.request.user.Settings.currency;
@ -145,8 +157,11 @@ export class PortfolioService {
});
}
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
public async getAccountsWithAggregations(
aUserId: string,
aFilters?: Filter[]
): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId, aFilters);
let totalBalanceInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
@ -170,7 +185,8 @@ export class PortfolioService {
}
public async getInvestments(
aImpersonationId: string
aImpersonationId: string,
groupBy?: GroupBy
): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
@ -191,28 +207,57 @@ export class PortfolioService {
return [];
}
const investments = portfolioCalculator.getInvestments().map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
});
let investments: InvestmentItem[];
// Add investment of today
const investmentOfToday = investments.filter((investment) => {
return investment.date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter((investment) => {
return isBefore(parseDate(investment.date), new Date());
if (groupBy === 'month') {
investments = portfolioCalculator.getInvestmentsByMonth().map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
// Add investment of current month
const dateOfCurrentMonth = format(
set(new Date(), { date: 1 }),
DATE_FORMAT
);
const investmentOfCurrentMonth = investments.filter(({ date }) => {
return date === dateOfCurrentMonth;
});
if (investmentOfCurrentMonth.length <= 0) {
investments.push({
date: dateOfCurrentMonth,
investment: 0
});
}
} else {
investments = portfolioCalculator
.getInvestments()
.map(({ date, investment }) => {
return {
date,
investment: investment.toNumber()
};
});
// Add investment of today
const investmentOfToday = investments.filter(({ date }) => {
return date === format(new Date(), DATE_FORMAT);
});
if (investmentOfToday.length <= 0) {
const pastInvestments = investments.filter(({ date }) => {
return isBefore(parseDate(date), new Date());
});
const lastInvestment = pastInvestments[pastInvestments.length - 1];
investments.push({
date: format(new Date(), DATE_FORMAT),
investment: lastInvestment?.investment ?? 0
});
}
}
return sortBy(investments, (investment) => {
@ -1290,6 +1335,10 @@ export class PortfolioService {
if (filters.length === 0) {
currentAccounts = await this.accountService.getAccounts(userId);
} else if (filters.length === 1 && filters[0].type === 'ACCOUNT') {
currentAccounts = await this.accountService.accounts({
where: { id: filters[0].id }
});
} else {
const accountIds = uniq(
orders.map(({ accountId }) => {

View File

@ -46,7 +46,6 @@ export class SymbolController {
* Must be after /lookup
*/
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData(

View File

@ -36,14 +36,7 @@ export class UserService {
}
public async getUser(
{
Account,
alias,
id,
permissions,
Settings,
subscription
}: UserWithSettings,
{ Account, id, permissions, Settings, subscription }: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const access = await this.prismaService.access.findMany({
@ -63,7 +56,6 @@ export class UserService {
}
return {
alias,
id,
permissions,
subscription,
@ -158,10 +150,6 @@ export class UserService {
let currentPermissions = getPermissions(user.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,7 @@
{
"LUNA1": "Terra",
"UNI1": "Uniswap"
"LUNA2": "Terra",
"SGB1": "Songbird",
"UNI1": "Uniswap",
"UST": "TerraUSD"
}

View File

@ -5,7 +5,12 @@ import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, {
logger:
process.env.NODE_ENV === 'production'
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
});
app.enableCors();
app.enableVersioning({
defaultVersion: '1',

View File

@ -33,8 +33,8 @@ export class ConfigurationService {
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: '0.0.0.0' }),
JWT_SECRET_KEY: str({}),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
PORT: port({ default: 3333 }),
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: host({ default: 'localhost' }),

View File

@ -168,6 +168,7 @@ export class DataProviderService {
const response: {
[symbol: string]: IDataProviderResponse;
} = {};
const startTimeTotal = performance.now();
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
@ -176,25 +177,59 @@ export class DataProviderService {
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const dataProvider = this.getDataProvider(DataSource[dataSource]);
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
);
const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER;
for (
let i = 0;
i < symbols.length;
i += maximumNumberOfSymbolsPerRequest
) {
const startTimeDataSource = performance.now();
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
const symbolsChunk = symbols.slice(
i,
i + maximumNumberOfSymbolsPerRequest
);
const promise = Promise.resolve(dataProvider.getQuotes(symbolsChunk));
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
response[symbol] = dataProviderResponse;
}
Logger.debug(
`Fetched ${symbolsChunk.length} quotes from ${dataSource} in ${(
(performance.now() - startTimeDataSource) /
1000
).toFixed(3)} seconds`
);
})
);
}
}
await Promise.all(promises);
Logger.debug('------------------------------------------------');
Logger.debug(
`Fetched ${items.length} quotes in ${(
(performance.now() - startTimeTotal) /
1000
).toFixed(3)} seconds`
);
Logger.debug('================================================');
return response;
}

View File

@ -81,6 +81,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
}
}
public getMaxNumberOfSymbolsPerRequest() {
// It is not recommended using more than 15-20 tickers per request
// https://eodhistoricaldata.com/financial-apis/live-realtime-stocks-api
return 20;
}
public getName(): DataSource {
return DataSource.EOD_HISTORICAL_DATA;
}

View File

@ -20,6 +20,8 @@ export interface DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; // TODO: Return only one symbol
getMaxNumberOfSymbolsPerRequest?(): number;
getName(): DataSource;
getQuotes(

View File

@ -37,10 +37,15 @@ export class YahooFinanceService implements DataProviderInterface {
}
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace(
let symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
);
if (symbol.includes('=X') && !symbol.includes(this.baseCurrency)) {
symbol = `${this.baseCurrency}${symbol}`;
}
return symbol.replace('=X', '');
}
@ -203,6 +208,10 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}
public getName(): DataSource {
return DataSource.YAHOO;
}
@ -261,6 +270,16 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
if (yahooFinanceSymbols.includes('USDUSX=X')) {
// Convert USD to USX (cent)
response['USDUSX'] = {
currency: 'USX',
dataSource: this.getName(),
marketPrice: new Big(1).mul(100).toNumber(),
marketState: 'open'
};
}
return response;
} catch (error) {
Logger.error(error, 'YahooFinanceService');

View File

@ -122,15 +122,6 @@ export class ExchangeRateDataService {
return 0;
}
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate);
});
if (hasNaN) {
// Reinitialize if data is not loaded correctly
this.initialize();
}
let factor = 1;
if (aFromCurrency !== aToCurrency) {

View File

@ -23,8 +23,8 @@ export interface Environment extends CleanedEnvAccessors {
GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number;
PORT: number;
RAKUTEN_RAPID_API_KEY: string;
REDIS_HOST: string;

View File

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { RouterModule, Routes, TitleStrategy } from '@angular/router';
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
import { ModulePreloadService } from './core/module-preload.service';
@ -59,6 +60,11 @@ const routes: Routes = [
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule)
},
{
path: 'demo',
loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
},
{
path: 'en/blog/2021/07/hello-ghostfolio',
loadChildren: () =>
@ -73,6 +79,25 @@ const routes: Routes = [
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'en/blog/2022/07/ghostfolio-meets-internet-identity',
loadChildren: () =>
import(
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
},
{
path: 'en/blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
},
{
path: 'faq',
loadChildren: () =>
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
},
{
path: 'features',
loadChildren: () =>
@ -85,6 +110,13 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
},
{
path: 'markets',
loadChildren: () =>
import('./pages/markets/markets-page.module').then(
(m) => m.MarketsPageModule
)
},
{
path: 'p',
loadChildren: () =>
@ -203,7 +235,10 @@ const routes: Routes = [
}
)
],
providers: [ModulePreloadService],
providers: [
ModulePreloadService,
{ provide: TitleStrategy, useClass: PageTitleStrategy }
],
exports: [RouterModule]
})
export class AppRoutingModule {}

View File

@ -15,13 +15,17 @@
>
<div class="row">
<div class="col-md-8 offset-md-2 text-center">
<a *ngIf="canCreateAccount" class="text-center" [routerLink]="['/']">
<a
*ngIf="canCreateAccount"
class="text-center"
[routerLink]="['/register']"
>
<div
class="cursor-pointer d-inline-block info-message px-3 py-2"
(click)="onCreateAccount()"
>
<span i18n>You are using the Live Demo.</span>
<a class="ml-2" href="#" i18n>Create Account</a>
<span class="a ml-2" i18n>Create Account</span>
</div></a
>
<div

View File

@ -17,7 +17,7 @@
border-radius: 2rem;
font-size: 80%;
a {
.a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
}

View File

@ -1,6 +1,8 @@
import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatChipsModule } from '@angular/material/chips';
import {
DateAdapter,
MAT_DATE_FORMATS,
@ -38,6 +40,8 @@ export function NgxStripeFactory(): string {
GfHeaderModule,
HttpClientModule,
MarkdownModule.forRoot(),
MatAutocompleteModule,
MatChipsModule,
MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme',
isAutoContrast: true,

View File

@ -10,7 +10,6 @@ import { AccessTableComponent } from './access-table.component';
declarations: [AccessTableComponent],
exports: [AccessTableComponent],
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioAccessTableModule {}

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

View File

@ -0,0 +1,112 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AccountType } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { AccountDetailDialogParams } from './interfaces/interfaces';
@Component({
host: { class: 'd-flex flex-column h-100' },
selector: 'gf-account-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'account-detail-dialog.html',
styleUrls: ['./account-detail-dialog.component.scss']
})
export class AccountDetailDialog implements OnDestroy, OnInit {
public accountType: AccountType;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
public user: User;
public valueInBaseCurrency: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<AccountDetailDialog>,
private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit(): void {
this.dataService
.fetchAccount(this.data.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
this.accountType = accountType;
this.name = name;
this.platformName = Platform?.name;
this.valueInBaseCurrency = valueInBaseCurrency;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchActivities({
filters: [{ id: this.data.accountId, type: 'ACCOUNT' }]
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.orders = activities;
this.changeDetectorRef.markForCheck();
});
}
public onClose(): void {
this.dialogRef.close();
}
public onExport() {
this.dataService
.fetchExport(
this.orders.map((order) => {
return order.id;
})
)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${this.name
.replace(/\s+/g, '-')
.toLowerCase()}-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
format: 'json'
});
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,64 @@
<gf-dialog-header
mat-dialog-title
position="center"
[deviceType]="data.deviceType"
[title]="name"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<div class="container p-0">
<div class="row">
<div class="col-12 d-flex justify-content-center mb-3">
<gf-value
size="large"
[currency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[value]="valueInBaseCurrency"
></gf-value>
</div>
</div>
<div class="row">
<div class="col-6 mb-3">
<gf-value
label="Account Type"
size="medium"
[value]="accountType"
></gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
label="Platform"
size="medium"
[value]="platformName"
></gf-value>
</div>
</div>
<div *ngIf="orders?.length > 0" class="row">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<gf-activities-table
[activities]="orders"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale"
[showActions]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@ -0,0 +1,27 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AccountDetailDialog } from './account-detail-dialog.component';
@NgModule({
declarations: [AccountDetailDialog],
imports: [
CommonModule,
GfActivitiesTableModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAccountDetailDialogModule {}

View File

@ -0,0 +1,5 @@
export interface AccountDetailDialogParams {
accountId: string;
deviceType: string;
hasImpersonationId: boolean;
}

View File

@ -65,7 +65,7 @@
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<span class="d-block d-sm-none">#</span>
<span class="d-none d-sm-block" i18n>Transactions</span>
<span class="d-none d-sm-block" i18n>Activities</span>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{
@ -212,7 +212,12 @@
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr
*matRowDef="let row; columns: displayedColumns"
class="cursor-pointer"
mat-row
(click)="onOpenAccountDetailDialog(row.id)"
></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row

View File

@ -9,6 +9,7 @@ import {
Output
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@ -39,7 +40,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public constructor(private router: Router) {}
public ngOnInit() {}
@ -75,6 +76,12 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
}
}
public onOpenAccountDetailDialog(accountId: string) {
this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true }
});
}
public onUpdateAccount(aAccount: AccountModel) {
this.accountToUpdate.emit(aAccount);
}

View File

@ -25,7 +25,6 @@ import { AccountsTableComponent } from './accounts-table.component';
NgxSkeletonLoaderModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAccountsTableModule {}

View File

@ -9,7 +9,6 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark
declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent],
imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataDetailModule {}

View File

@ -11,7 +11,6 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
@NgModule({
declarations: [MarketDataDetailDialog],
exports: [],
imports: [
CommonModule,
FormsModule,
@ -22,7 +21,6 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
MatInputModule,
ReactiveFormsModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfMarketDataDetailDialogModule {}

View File

@ -29,11 +29,10 @@
<td class="mat-cell px-1 py-2">
<div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block"
>{{ userItem.alias || userItem.id }}</span
>{{ userItem.id }}</span
>
<span class="d-inline-block d-sm-none"
>{{ userItem.alias || (userItem.id | slice:0:5) +
'...' }}</span
>{{ (userItem.id | slice:0:5) + '...' }}</span
>
<gf-premium-indicator
*ngIf="userItem?.subscription?.type === 'Premium'"

View File

@ -8,7 +8,6 @@ import { DialogFooterComponent } from './dialog-footer.component';
declarations: [DialogFooterComponent],
exports: [DialogFooterComponent],
imports: [CommonModule, MatButtonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfDialogFooterModule {}

View File

@ -8,7 +8,6 @@ import { DialogHeaderComponent } from './dialog-header.component';
declarations: [DialogHeaderComponent],
exports: [DialogHeaderComponent],
imports: [CommonModule, MatButtonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfDialogHeaderModule {}

View File

@ -124,13 +124,11 @@
: 'radio-button-on-outline'
"
></ion-icon>
<span *ngIf="user?.alias">{{ user.alias }}</span>
<span *ngIf="!user?.alias" i18n><span></span>Me</span>
<span i18n>Me</span>
</button>
<button
*ngFor="let accessItem of user?.access"
class="align-items-center d-flex"
disabled="false"
mat-menu-item
(click)="impersonateAccount(accessItem.id)"
>
@ -269,6 +267,18 @@
[routerLink]="['/pricing']"
>Pricing</a
>
<a
*ngIf="hasPermissionToAccessFearAndGreedIndex"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'markets',
'text-decoration-underline': currentRoute === 'markets'
}"
[routerLink]="['/markets']"
>Markets</a
>
<a
class="d-none d-sm-block mx-1 no-min-width px-1"
href="https://github.com/ghostfolio/ghostfolio"

View File

@ -37,6 +37,7 @@ export class HeaderComponent implements OnChanges {
public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public impersonationId: string;
public isMenuOpen: boolean;
@ -73,6 +74,11 @@ export class HeaderComponent implements OnChanges {
this.user?.permissions,
permissions.accessAdminControl
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
}
public impersonateAccount(aId: string) {

View File

@ -21,7 +21,6 @@ import { HeaderComponent } from './header.component';
MatToolbarModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfHeaderModule {}

View File

@ -11,7 +11,6 @@ import { HomeHoldingsComponent } from './home-holdings.component';
@NgModule({
declarations: [HomeHoldingsComponent],
exports: [],
imports: [
CommonModule,
GfPositionDetailDialogModule,
@ -21,7 +20,6 @@ import { HomeHoldingsComponent } from './home-holdings.component';
MatCardModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfHomeHoldingsModule {}

View File

@ -44,49 +44,49 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.user.permissions,
permissions.accessFearAndGreedIndex
);
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchSymbolItem({
dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice;
this.historicalData = [
...historicalData,
{
date: resetHours(new Date()).toISOString(),
value: marketPrice
}
];
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnInit() {}
public ngOnInit() {
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex
);
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchSymbolItem({
dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice;
this.historicalData = [
...historicalData,
{
date: resetHours(new Date()).toISOString(),
value: marketPrice
}
];
this.changeDetectorRef.markForCheck();
});
}
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();

View File

@ -28,16 +28,17 @@
<div class="mb-3 row">
<div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark
*ngFor="let benchmark of benchmarks"
class="py-2"
[benchmark]="benchmark"
[benchmarks]="benchmarks"
[locale]="user?.settings?.locale"
></gf-benchmark>
<gf-benchmark
*ngIf="!benchmarks"
class="py-2"
[benchmark]="undefined"
></gf-benchmark>
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
</div>
</div>
</div>

View File

@ -3,19 +3,20 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { HomeMarketComponent } from './home-market.component';
@NgModule({
declarations: [HomeMarketComponent],
exports: [],
exports: [HomeMarketComponent],
imports: [
CommonModule,
GfBenchmarkModule,
GfFearAndGreedIndexModule,
GfLineChartModule
GfLineChartModule,
NgxSkeletonLoaderModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfHomeMarketModule {}

View File

@ -2,26 +2,29 @@
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
>
<div class="row w-100">
<div class="chart-container col">
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
</div>
<gf-line-chart
class="position-absolute"
symbol="Performance"
[currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems"
[hidden]="historicalDataItems?.length === 0"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
</div>

View File

@ -10,7 +10,6 @@ import { HomeOverviewComponent } from './home-overview.component';
@NgModule({
declarations: [HomeOverviewComponent],
exports: [],
imports: [
CommonModule,
GfLineChartModule,
@ -19,7 +18,6 @@ import { HomeOverviewComponent } from './home-overview.component';
GfToggleModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfHomeOverviewModule {}

View File

@ -5,7 +5,8 @@
.chart-container {
aspect-ratio: 16 / 9;
max-height: 50vh;
height: auto;
max-width: 67rem;
// Fallback for aspect-ratio (using padding hack)
@supports not (aspect-ratio: 16 / 9) {

View File

@ -8,14 +8,12 @@ import { HomeSummaryComponent } from './home-summary.component';
@NgModule({
declarations: [HomeSummaryComponent],
exports: [],
imports: [
CommonModule,
GfPortfolioSummaryModule,
MatCardModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfHomeSummaryModule {}

View File

@ -1,3 +1,7 @@
:host {
display: block;
ngx-skeleton-loader {
height: 100%;
}
}

View File

@ -13,7 +13,7 @@ import {
getTooltipPositionerMapTop,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import {
getBackgroundColor,
getDateFormatString,
@ -22,7 +22,10 @@ import {
transformTickToAbbreviation
} from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { GroupBy } from '@ghostfolio/common/types';
import {
BarController,
BarElement,
Chart,
LineController,
LineElement,
@ -31,6 +34,7 @@ import {
TimeScale,
Tooltip
} from 'chart.js';
import annotationPlugin from 'chartjs-plugin-annotation';
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
@Component({
@ -42,9 +46,11 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
export class InvestmentChartComponent implements OnChanges, OnDestroy {
@Input() currency: string;
@Input() daysInMarket: number;
@Input() groupBy: GroupBy;
@Input() investments: InvestmentItem[];
@Input() isInPercent = false;
@Input() locale: string;
@Input() savingsRate = 0;
@ViewChild('chartCanvas') chartCanvas;
@ -53,6 +59,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
public constructor() {
Chart.register(
annotationPlugin,
BarController,
BarElement,
LinearScale,
LineController,
LineElement,
@ -78,7 +87,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
private initialize() {
this.isLoading = true;
if (this.investments?.length > 0) {
if (!this.groupBy && this.investments?.length > 0) {
// Extend chart by 5% of days in market (before)
const firstItem = this.investments[0];
this.investments.unshift({
@ -102,13 +111,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
const data = {
labels: this.investments.map((position) => {
return position.date;
labels: this.investments.map((investmentItem) => {
return investmentItem.date;
}),
datasets: [
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
borderWidth: this.groupBy ? 0 : 2,
data: this.investments.map((position) => {
return position.investment;
}),
@ -137,6 +147,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data,
options: {
animation: false,
elements: {
line: {
tension: 0
@ -150,6 +161,39 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
interaction: { intersect: false, mode: 'index' },
maintainAspectRatio: true,
plugins: <unknown>{
annotation: {
annotations: {
savingsRate: this.savingsRate
? {
borderColor: `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.75)`,
borderWidth: 1,
label: {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderRadius: 2,
color: 'white',
content: 'Savings Rate',
display: true,
font: { size: '10px', weight: 'normal' },
padding: {
x: 4,
y: 2
},
position: 'start'
},
scaleID: 'y',
type: 'line',
value: this.savingsRate
}
: undefined,
yAxis: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: 1,
scaleID: 'y',
type: 'line',
value: 0
}
}
},
legend: {
display: false
},
@ -164,6 +208,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
display: true,
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
borderWidth: this.groupBy ? 0 : 1,
color: `rgba(${getTextColor()}, 0.8)`,
display: false
},
@ -178,8 +223,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
grid: {
borderColor: `rgba(${getTextColor()}, 0.1)`,
color: `rgba(${getTextColor()}, 0.8)`,
display: false
display: false,
drawBorder: false
},
position: 'right',
ticks: {
callback: (value: number) => {
return transformTickToAbbreviation(value);
@ -192,12 +239,12 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
}
},
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
type: 'line'
type: this.groupBy ? 'bar' : 'line'
});
this.isLoading = false;
}
}
this.isLoading = false;
}
private getTooltipPluginConfiguration() {

View File

@ -1,10 +1,13 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { InternetIdentityService } from '@ghostfolio/client/services/internet-identity.service';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
@Component({
selector: 'gf-login-with-access-token-dialog',
@ -16,7 +19,10 @@ export class LoginWithAccessTokenDialog {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
private settingsStorageService: SettingsStorageService
private internetIdentityService: InternetIdentityService,
private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService
) {}
ngOnInit() {}
@ -31,4 +37,14 @@ export class LoginWithAccessTokenDialog {
public onClose() {
this.dialogRef.close();
}
public async onLoginWithInternetIdentity() {
try {
const { authToken } = await this.internetIdentityService.login();
this.tokenStorageService.saveToken(authToken);
this.dialogRef.close();
this.router.navigate(['/']);
} catch {}
}
}

View File

@ -5,16 +5,7 @@
></gf-dialog-header>
<div mat-dialog-content>
<div>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="text-center">
<a color="accent" href="/api/v1/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Sign in with Google</span></a
>
</div>
<div class="my-3 text-center text-muted" i18n>or</div>
</ng-container>
<div class="align-items-center d-flex flex-column">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Security Token</mat-label>
<textarea
@ -24,6 +15,29 @@
[(ngModel)]="data.accessToken"
></textarea>
</mat-form-field>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="my-3 text-center text-muted" i18n>or</div>
<div class="d-flex flex-column">
<button
class="mb-2"
mat-stroked-button
(click)="onLoginWithInternetIdentity()"
>
<img
class="mr-2"
src="./assets/icons/internet-computer.svg"
style="height: 0.75rem"
/><span i18n>Sign in with Internet Identity</span>
</button>
<a href="/api/v1/auth/google" mat-stroked-button
><img
class="mr-2"
src="./assets/icons/google.svg"
style="height: 1rem"
/><span i18n>Sign in with Google</span></a
>
</div>
</ng-container>
</div>
</div>
<div mat-dialog-actions>

View File

@ -13,7 +13,6 @@ import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.com
@NgModule({
declarations: [LoginWithAccessTokenDialog],
exports: [],
imports: [
CommonModule,
FormsModule,
@ -26,7 +25,6 @@ import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.com
ReactiveFormsModule,
TextFieldModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LoginWithAccessTokenDialogModule {}

View File

@ -12,4 +12,12 @@
}
}
}
.mat-form-field {
::ng-deep {
.mat-form-field-wrapper {
padding-bottom: 0;
}
}
}
}

View File

@ -8,7 +8,6 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
@NgModule({
declarations: [PortfolioPerformanceComponent],
exports: [PortfolioPerformanceComponent],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
providers: []
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule]
})
export class GfPortfolioPerformanceModule {}

View File

@ -15,7 +15,6 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
@NgModule({
declarations: [PositionDetailDialog],
exports: [],
imports: [
CommonModule,
GfActivitiesTableModule,
@ -29,7 +28,6 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
MatDialogModule,
NgxSkeletonLoaderModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionDetailDialogModule {}

View File

@ -23,7 +23,6 @@ import { PositionComponent } from './position.component';
NgxSkeletonLoaderModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionModule {}

View File

@ -142,4 +142,15 @@
</button>
</div>
<div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateActivity && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>
<mat-paginator class="d-none" [pageSize]="pageSize"></mat-paginator>

View File

@ -27,6 +27,7 @@ import { Subject, Subscription } from 'rxjs';
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean;
@Input() hasPermissionToShowValues = true;
@Input() locale: string;
@Input() pageSize = Number.MAX_SAFE_INTEGER;

View File

@ -15,7 +15,6 @@ import { PositionsComponent } from './positions.component';
GfPositionModule,
MatButtonModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPositionsModule {}

View File

@ -8,7 +8,6 @@ import { RuleComponent } from './rule.component';
declarations: [RuleComponent],
exports: [RuleComponent],
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfRuleModule {}

View File

@ -19,7 +19,6 @@ import { RulesComponent } from './rules.component';
MatButtonModule,
MatCardModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class RulesModule {}

View File

@ -7,7 +7,6 @@ import { SymbolIconComponent } from './symbol-icon.component';
declarations: [SymbolIconComponent],
exports: [SymbolIconComponent],
imports: [CommonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfSymbolIconModule {}

View File

@ -8,7 +8,6 @@ import { ToggleComponent } from './toggle.component';
@NgModule({
declarations: [ToggleComponent],
exports: [ToggleComponent],
imports: [CommonModule, MatRadioModule, ReactiveFormsModule],
providers: []
imports: [CommonModule, MatRadioModule, ReactiveFormsModule]
})
export class GfToggleModule {}

View File

@ -7,7 +7,6 @@ import { WorldMapChartComponent } from './world-map-chart.component';
@NgModule({
declarations: [WorldMapChartComponent],
exports: [WorldMapChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: []
imports: [CommonModule, NgxSkeletonLoaderModule]
})
export class GfWorldMapChartModule {}

View File

@ -5,13 +5,12 @@ import {
Router,
RouterStateSnapshot
} from '@angular/router';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ViewMode } from '@prisma/client';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { SettingsStorageService } from '../services/settings-storage.service';
import { UserService } from '../services/user/user.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
private static PUBLIC_PAGE_ROUTES = [
@ -20,8 +19,11 @@ export class AuthGuard implements CanActivate {
'/about/privacy-policy',
'/blog',
'/de/blog',
'/demo',
'/en/blog',
'/faq',
'/features',
'/markets',
'/p',
'/pricing',
'/register',
@ -35,11 +37,10 @@ export class AuthGuard implements CanActivate {
) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
if (route.queryParams?.utm_source) {
this.settingsStorageService.setSetting(
'utm_source',
route.queryParams?.utm_source
);
const utmSource = route.queryParams?.utm_source;
if (utmSource) {
this.settingsStorageService.setSetting('utm_source', utmSource);
}
return new Promise<boolean>((resolve) => {
@ -47,7 +48,10 @@ export class AuthGuard implements CanActivate {
.get()
.pipe(
catchError(() => {
if (route.queryParams?.utm_source) {
if (utmSource === 'ios') {
this.router.navigate(['/demo']);
resolve(false);
} else if (utmSource === 'trusted-web-activity') {
this.router.navigate(['/register']);
resolve(false);
} else if (

View File

@ -5,7 +5,6 @@ import {
HttpRequest
} from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { Observable } from 'rxjs';
import { ImpersonationStorageService } from '../services/impersonation-storage.service';
@ -18,7 +17,6 @@ const TOKEN_HEADER_KEY = 'Authorization';
export class AuthInterceptor implements HttpInterceptor {
public constructor(
private impersonationStorageService: ImpersonationStorageService,
private router: Router,
private tokenStorageService: TokenStorageService
) {}

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component';
const routes: Routes = [
{ path: '', component: AboutPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: AboutPageComponent,
path: '',
title: 'About'
}
];
@NgModule({

View File

@ -173,8 +173,18 @@
</div>
<div class="row">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
i18n
mat-stroked-button
[routerLink]="['/faq']"
>FAQ</a
>
</div>
<div
class="col-md-4 col-xs-12 my-2"
class="col-md-3 col-xs-12 my-2"
[ngClass]="{ 'offset-md-4': !hasPermissionForBlog }"
>
<a
@ -186,7 +196,7 @@
>Changelog & License</a
>
</div>
<div *ngIf="hasPermissionForSubscription" class="col-md-4 col-xs-12 my-2">
<div *ngIf="hasPermissionForSubscription" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"
@ -196,7 +206,7 @@
>Privacy Policy</a
>
</div>
<div *ngIf="hasPermissionForBlog" class="col-md-4 col-xs-12 my-2">
<div *ngIf="hasPermissionForBlog" class="col-md-3 col-xs-12 my-2">
<a
class="py-2 w-100"
color="primary"

View File

@ -9,7 +9,6 @@ import { AboutPageComponent } from './about-page.component';
@NgModule({
declarations: [AboutPageComponent],
exports: [],
imports: [
AboutPageRoutingModule,
CommonModule,
@ -17,7 +16,6 @@ import { AboutPageComponent } from './about-page.component';
MatButtonModule,
MatCardModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AboutPageModule {}

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ChangelogPageComponent } from './changelog-page.component';
const routes: Routes = [
{ path: '', component: ChangelogPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: ChangelogPageComponent,
path: '',
title: 'Changelog & License'
}
];
@NgModule({

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PrivacyPolicyPageComponent } from './privacy-policy-page.component';
const routes: Routes = [
{ path: '', component: PrivacyPolicyPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: PrivacyPolicyPageComponent,
path: '',
title: 'Privacy Policy'
}
];
@NgModule({

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AccountPageComponent } from './account-page.component';
const routes: Routes = [
{ path: '', component: AccountPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: AccountPageComponent,
path: '',
title: 'My Ghostfolio'
}
];
@NgModule({

View File

@ -8,11 +8,10 @@
<div class="col">
<mat-card class="mb-3">
<mat-card-content>
<div *ngIf="user.alias" class="d-flex py-1">
<div class="pr-1 w-50" i18n>Alias</div>
<div class="pl-1 w-50">{{ user.alias }}</div>
</div>
<div *ngIf="user?.subscription" class="d-flex py-1">
<div
*ngIf="hasPermissionToUpdateUserSettings && user?.subscription"
class="d-flex py-1"
>
<div class="pr-1 w-50" i18n>Membership</div>
<div class="pl-1 w-50">
<div class="align-items-center d-flex mb-1">
@ -171,7 +170,7 @@
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>ID</div>
<div class="pr-1 w-50" i18n>User ID</div>
<div class="pl-1 w-50">{{ user?.id }}</div>
</div>
</mat-card-content>

View File

@ -19,7 +19,6 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di
@NgModule({
declarations: [AccountPageComponent],
exports: [],
imports: [
AccountPageRoutingModule,
CommonModule,
@ -37,7 +36,6 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di
MatSlideToggleModule,
ReactiveFormsModule,
RouterModule
],
providers: []
]
})
export class AccountPageModule {}

View File

@ -10,7 +10,6 @@ import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.com
@NgModule({
declarations: [CreateOrUpdateAccessDialog],
exports: [],
imports: [
CommonModule,
FormsModule,
@ -19,7 +18,6 @@ import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.com
MatFormFieldModule,
MatSelectModule,
ReactiveFormsModule
],
providers: []
]
})
export class GfCreateOrUpdateAccessDialogModule {}

View File

@ -5,7 +5,12 @@ import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AccountsPageComponent } from './accounts-page.component';
const routes: Routes = [
{ path: '', component: AccountsPageComponent, canActivate: [AuthGuard] }
{
canActivate: [AuthGuard],
component: AccountsPageComponent,
path: '',
title: 'Accounts'
}
];
@NgModule({

View File

@ -3,6 +3,8 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
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';
@ -48,12 +50,17 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog'] && this.hasPermissionToCreateAccount) {
if (params['accountId'] && params['accountDetailDialog']) {
this.openAccountDetailDialog(params['accountId']);
} else if (
params['createDialog'] &&
this.hasPermissionToCreateAccount
) {
this.openCreateAccountDialog();
} else if (params['editDialog']) {
if (this.accounts) {
const account = this.accounts.find((account) => {
return account.id === params['transactionId'];
return account.id === params['accountId'];
});
this.openUpdateAccountDialog(account);
@ -139,7 +146,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public onUpdateAccount(aAccount: AccountModel) {
this.router.navigate([], {
queryParams: { editDialog: true, transactionId: aAccount.id }
queryParams: { accountId: aAccount.id, editDialog: true }
});
}
@ -197,6 +204,26 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openAccountDetailDialog(aAccountId: string) {
const dialogRef = this.dialog.open(AccountDetailDialog, {
autoFocus: false,
data: <AccountDetailDialogParams>{
accountId: aAccountId,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private openCreateAccountDialog(): void {
const dialogRef = this.dialog.open(CreateOrUpdateAccountDialog, {
data: {

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfAccountDetailDialogModule } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.module';
import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-table/accounts-table.module';
import { AccountsPageRoutingModule } from './accounts-page-routing.module';
@ -10,16 +11,15 @@ import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-
@NgModule({
declarations: [AccountsPageComponent],
exports: [],
imports: [
AccountsPageRoutingModule,
CommonModule,
GfAccountDetailDialogModule,
GfAccountsTableModule,
GfCreateOrUpdateAccountDialogModule,
MatButtonModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AccountsPageModule {}

View File

@ -50,6 +50,17 @@
</mat-select>
</mat-form-field>
</div>
<div *ngIf="data.account.id">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Account ID</mat-label>
<input
disabled
matInput
name="accountId"
[(ngModel)]="data.account.id"
/>
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>

View File

@ -11,7 +11,6 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
@NgModule({
declarations: [CreateOrUpdateAccountDialog],
exports: [],
imports: [
CommonModule,
FormsModule,
@ -21,7 +20,6 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
MatInputModule,
MatSelectModule,
ReactiveFormsModule
],
providers: []
]
})
export class GfCreateOrUpdateAccountDialogModule {}

View File

@ -10,8 +10,6 @@ import { AdminPageComponent } from './admin-page.component';
const routes: Routes = [
{
path: '',
component: AdminPageComponent,
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
@ -19,7 +17,10 @@ const routes: Routes = [
{ path: 'market-data', component: AdminMarketDataComponent },
{ path: 'overview', component: AdminOverviewComponent },
{ path: 'users', component: AdminUsersComponent }
]
],
component: AdminPageComponent,
path: '',
title: 'Admin Control'
}
];

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