Compare commits

...

62 Commits

Author SHA1 Message Date
373a2015c0 Release 1.19.0 (#176) 2021-06-17 23:01:59 +02:00
66c955ad6c Feature/ghostfolio in numbers (#175)
* Add Ghostfolio in numbers section

* Update changelog
2021-06-17 22:59:48 +02:00
a2440fc067 Release 1.18.0 (#174) 2021-06-16 17:34:43 +02:00
3d7624d997 Feature/improve twa onboarding (#173)
* Improve TWA onboarding (Redirect to the account registration page)

* Update changelog
2021-06-16 17:31:31 +02:00
0264b592b9 Feature/improve investments by sector (#172)
* Improve investments analysis by sector

* Update changelog
2021-06-16 17:05:43 +02:00
198eaf57d3 Release 1.17.0 (#171) 2021-06-15 21:17:41 +02:00
6783ea2ebb Feature/upgrade various frontend dependencies (#170)
* Upgrade frontend dependencies

* Update changelog
2021-06-15 21:15:48 +02:00
a35701fe24 Feature/upgrade to angular 12 (#169)
* Upgrade to Angular 12

* Update changelog
2021-06-15 21:03:55 +02:00
5db90f1787 Feature/improve error page of fingerprint sign in (#167)
* Improve error page

* Update changelog
2021-06-15 09:47:18 +02:00
81fe538484 Order attribute 2021-06-15 09:43:48 +02:00
51884913be Feature/disable fingerprint sign in in demo account page (#163)
* Disable fingerprint toggle for demo user

* Update changelog
2021-06-15 09:21:53 +02:00
8886082dfa Feature/upgrade eslint and prettier dependencies (#164)
* Upgrade eslint and prettier dependencies

* Feature/upgrade date fns to version 2.22.1 (#165)

* Feature/upgrade chart.js to version 3.3.2 (#166)

* Update changelog
2021-06-15 09:17:27 +02:00
3b12e5b85b Release 1.16.0 (#162) 2021-06-14 22:00:00 +02:00
6c1119caec Restrict webauthn to fingerprint only and improve UX (#161)
* Restrict webauthn to fingerprint only

* Move webauthn login to separate page /webauthn

* Stay signed in with social login

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-06-14 21:57:09 +02:00
698d5ec3b7 Release 1.15.0 (#160) 2021-06-14 16:15:50 +02:00
e87c942cb8 Add webauthn (#82)
* Add webauthn

* Complete WebAuthn device sign up and login

* Move device registration to account page
* Replace the token login with a WebAuthn prompt if the current device has been registered
* Mark the current device in the list of registered auth devices

* Fix after rebase

* Fix tests

* Disable "Add current device" button if current device is registered

* Add option to "Stay signed in"

* Remove device list feature, sign in with deviceId instead

* Improve usability

* Update changelog

Co-authored-by: Matthias Frey <mfrey43@gmail.com>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-06-14 16:09:40 +02:00
f7860a9799 Feature/introduce max items in portfolio proportion chart (#159)
* Add option to limit items

* Update changelog
2021-06-14 14:18:02 +02:00
c519eb0e99 Bugfix/fix last activity column (#158)
* Fix last activity (only values in the past)

* Update changelog
2021-06-14 14:06:24 +02:00
8314b98f81 Feature/improve tables (#157)
* Improve tables

* Update changelog
2021-06-14 14:02:25 +02:00
194cf1ddcc Feature/clean up analysis page (#155)
* Clean up analysis page

* Update changelog
2021-06-14 13:55:15 +02:00
7da6478699 Improve server start instructions (#156) 2021-06-12 22:29:56 +02:00
4f2bbba782 Release 1.14.0 (#154) 2021-06-09 20:36:31 +02:00
9eb25f6c9e Feature/connect or create logic for symbol profile (#153)
* Add connectOrCreate logic

* Extend seed

* Update changelog
2021-06-09 20:35:02 +02:00
f74b00446c Feature/improve world map chart (#152)
* Improve world map chart

* Update changelog
2021-06-09 20:32:39 +02:00
beb7e6ec34 Release 1.13.0 (#151) 2021-06-08 22:02:11 +02:00
2eafc042ad Feature/add world map (#150)
* Add a global heat map

* Update changelog
2021-06-08 21:59:46 +02:00
74954bc51d Release 1.12.0 (#149) 2021-06-06 15:33:20 +02:00
6a03120225 Feature/add symbol profile model (#148)
* Add symbol profile model and positions by country chart

* Add positions by continent chart

* Fix tests

* Extend seed

* Update changelog
2021-06-06 15:31:28 +02:00
21504573b4 Release 1.11.0 (#147) 2021-06-05 17:30:59 +02:00
fabd912fba Setup initial prisma migration (#146) 2021-06-05 17:20:52 +02:00
00b42855b6 Feature/upgrade prisma to 2.24.1 (#145)
* Upgrade prisma

* Update changelog

* Update database push script
2021-06-05 17:19:38 +02:00
ef272360fb Feature/render average prices in position detail chart (#144)
* Render average buy prices

* Update changelog
2021-06-05 17:17:53 +02:00
026a5011d4 Feature/add account registration page (#141)
* Add account registration page

* Update changelog
2021-06-05 17:16:07 +02:00
aa4206af0e Feature/various frontend improvements 2 (#140)
* Change buttons to links

* Update changelog
2021-06-05 17:11:03 +02:00
7788465272 Release 1.10.1 (#139) 2021-06-02 21:24:27 +02:00
3066dfd805 Release 1.10.0 (#138) 2021-06-02 20:56:06 +02:00
34303163bc Various frontend improvements (#137)
* Various frontend improvements

* Clean up import
2021-06-02 20:54:12 +02:00
e7fbcd4fa0 Feature/extend pricing page (#130)
* Extend pricing page

* Feature/align pricing page with subscription model (#135)

* Align pricing page with subscription model

* Update changelog
2021-06-02 20:15:53 +02:00
7c22969de1 Feature/move tools to sub path (#125)
* Move tools to sub path

* Update changelog
2021-06-02 20:10:44 +02:00
6623bc0113 Release 1.9.0 (#136) 2021-06-01 21:44:46 +02:00
146b5201b5 Feature/make x ray rules order consistent (#134)
* Make order of X-ray rules consistent

* Update changelog
2021-06-01 21:40:32 +02:00
b021fbde59 Feature/refactor to format distance to now strict (#133)
* Change from formatDistanceToNow to formatDistanceToNowStrict

* Update changelog
2021-06-01 21:38:55 +02:00
ec046b81a7 Fix style (#132) 2021-06-01 21:35:47 +02:00
aea497154a Feature/prettify symbols in transaction filtering component (#131)
* Prettify generic scraper symbols

* Update changelog
2021-06-01 21:34:53 +02:00
dc736d53b4 Fix sorting (#129)
* Fix sorting
2021-06-01 21:33:56 +02:00
5957b33779 Feature/enable labels on the x axis of the investment chart (#128)
* Enable x-axis labels

* Update changelog
2021-05-30 20:39:37 +02:00
bafdce56ad add yarn build:all to .travis.yml (#127) 2021-05-27 22:32:10 +02:00
42a2d404e4 Fix type errors (#126) 2021-05-27 21:12:55 +02:00
11b2379d98 Feature/respect data source in data gathering (#107)
* Respect data source in data gathering

* Update changelog

* optimize fetching from multiple data sources (#123)

* optimize fetching from multiple data sources

* improve performance by executing data gathering promises in parallel

* removed unused imports

* rename hasHistoricalData to canHandle

* Sort imports

* Clean up

Co-authored-by: Valentin Zickner <3200232+vzickner@users.noreply.github.com>
2021-05-27 20:50:10 +02:00
c0657a2e9e Extend README.md (#124)
* Add contributions welcome badge
* Add features
* State technology stack precisely
2021-05-24 21:11:06 +02:00
646dcb91c5 Release 1.8.0 (#122) 2021-05-24 16:32:11 +02:00
ad961f3039 Bugfix/fix missing header of public pages (#121)
* Fix missing header of public pages

* Update changelog
2021-05-24 16:28:42 +02:00
c16f743b07 Feature/add tools section (#120)
* Add tools section

* Update changelog
2021-05-24 16:25:59 +02:00
8e13f6ef9b Bugfix/fix performance chart (#119)
* Fix value of performance chart

* Update changelog
2021-05-24 16:24:54 +02:00
95bcdea69b Refactor cd to changeDetectorRef (#118) 2021-05-24 10:12:53 +02:00
0d6fe4a232 Feature/refactor user service as observable store (#117)
* Implement user service as observable store

* Clean up tokenStorageService usage

* Update changelog
2021-05-24 09:38:44 +02:00
ced4519412 Reorder (#116) 2021-05-22 16:14:16 +02:00
ef8b7718b1 Release 1.7.0 (#115) 2021-05-22 13:49:25 +02:00
b4762dc463 Bugfix/fix internal navigation with query param (#114)
* Fix internal navigation with query parameter

* Add guard

* Update changelog
2021-05-22 13:48:06 +02:00
9851cce382 Feature/hide footer on mobile (#113)
* Hide footer on mobile

* Improve about text

* Update changelog
2021-05-22 13:45:50 +02:00
a1460a98fd Release 1.6.0 (#112) 2021-05-22 10:22:46 +02:00
1a553a296f Feature/improve user table of admin control panel (#109)
* Improve user table

* Add index
* Increase limit
* Improve alignment of cell content

* Update changelog
2021-05-22 10:17:12 +02:00
157 changed files with 5894 additions and 3221 deletions

2
.env
View File

@ -5,9 +5,9 @@ REDIS_HOST=localhost
REDIS_PORT=6379 REDIS_PORT=6379
# POSTGRES # POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user POSTGRES_USER=user
POSTGRES_PASSWORD=password POSTGRES_PASSWORD=password
POSTGRES_DB=ghostfolio-db
ACCESS_TOKEN_SALT=GHOSTFOLIO ACCESS_TOKEN_SALT=GHOSTFOLIO
ALPHA_VANTAGE_API_KEY= ALPHA_VANTAGE_API_KEY=

View File

@ -8,3 +8,4 @@ before_script:
script: script:
- yarn format:check - yarn format:check
- yarn test - yarn test
- yarn build:all

View File

@ -5,6 +5,158 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.19.0 - 17.06.2021
### Added
- Added a _Ghostfolio in Numbers_ section to the about page
## 1.18.0 - 16.06.2021
### Changed
- Improved the pie chart: Investments by sector
- Improved the onboarding for TWA by redirecting to the account registration page
## 1.17.0 - 15.06.2021
### Changed
- Improved the error page of the sign in with fingerprint
- Disable the sign in with fingerprint selector for the demo user
- Upgraded `angular` from version `11.2.4` to `12.0.4`
- Upgraded `angular-material-css-vars` from version `1.1.2` to `1.2.0`
- Upgraded `chart.js` from version `3.2.1` to `3.3.2`
- Upgraded `date-fns` from version `2.19.0` to `2.22.1`
- Upgraded `eslint` and `prettier` dependencies
- Upgraded `ngx-device-detector` from version `2.0.6` to `2.1.1`
- Upgraded `ngx-markdown` from version `11.1.2` to `12.0.1`
## 1.16.0 - 14.06.2021
### Changed
- Improved the sign in with fingerprint
## 1.15.0 - 14.06.2021
### Added
- Added a counter column to the transactions table
- Added a label to indicate the default account in the accounts table
- Added an option to limit the items in pie charts
- Added sign in with fingerprint
### Changed
- Cleaned up the analysis page with an unused chart module
- Improved the cell alignment in the users table of the admin control panel
### Fixed
- Fixed the last activity column of users in the admin control panel
## 1.14.0 - 09.06.2021
### Added
- Added a connect or create symbol profile model logic on creating a new transaction
### Changed
- Improved the global heat map to visualize investments by country
## 1.13.0 - 08.06.2021
### Added
- Added a global heat map to visualize investments by country
## 1.12.0 - 06.06.2021
### Added
- Added a symbol profile model with additional data
- Added new pie charts: Investments by continent and country
## 1.11.0 - 05.06.2021
### Added
- Added a dedicated page for the account registration
- Rendered the average buy prices in the position detail chart (useful for recurring transactions)
- Introduced the initial prisma migration
### Changed
- Changed the buttons to links (`<a>`) on the tools page
- Upgraded `prisma` from version `2.20.1` to `2.24.1`
## 1.10.1 - 02.06.2021
### Fixed
- Fixed an optional type in the user interface
## 1.10.0 - 02.06.2021
### Changed
- Moved the tools to a sub path (`/tools`)
- Extended the pricing page and aligned with the subscription model
## 1.9.0 - 01.06.2021
### Added
- Added the year labels to the investment chart on the x-axis
### Changed
- Respected the data source attribute of the transactions model in the data management for historical data
- Prettified the generic scraper symbols in the transaction filtering component
- Changed to the strict mode of distance formatting between two given dates
### Fixed
- Fixed the sorting in various tables
- Made the order of the rules in the _X-ray_ section consistent
## 1.8.0 - 24.05.2021
### Added
- Added a section for _Analysis_, _X-ray_ and upcoming tools
### Changed
- Introduced a user service implemented as an observable store (single source of truth for state)
### Fixed
- Fixed the performance chart by considering the investment
- Fixed missing header of public pages (_About_, _Pricing_, _Resources_)
## 1.7.0 - 22.05.2021
### Changed
- Hid footer on mobile (except on landing page)
### Fixed
- Fixed the internal navigation of the _Zen Mode_ in combination with a query parameter
## 1.6.0 - 22.05.2021
### Added
- Added an index in the users table of the admin control panel
### Changed
- Improved the alignment in the users table of the admin control panel
## 1.5.0 - 22.05.2021 ## 1.5.0 - 22.05.2021
### Added ### Added
@ -135,7 +287,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the user table styling of the admin control panel - Improved the users table styling of the admin control panel
- Improved the background colors in the dark mode - Improved the background colors in the dark mode
## 0.92.0 - 25.04.2021 ## 0.92.0 - 25.04.2021
@ -143,7 +295,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Prepared further for multi accounts support: store account for new transactions - Prepared further for multi accounts support: store account for new transactions
- Added a horizontal scrollbar to the user table of the admin control panel - Added a horizontal scrollbar to the users table of the admin control panel
### Fixed ### Fixed
@ -170,7 +322,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the user table of the admin control panel - Improved the users table of the admin control panel
## 0.89.0 - 21.04.2021 ## 0.89.0 - 21.04.2021
@ -201,7 +353,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue in the user table of the admin control panel with missing data - Fixed an issue in the users table of the admin control panel with missing data
## 0.86.1 - 18.04.2021 ## 0.86.1 - 18.04.2021
@ -216,7 +368,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the about page for the new license - Changed the about page for the new license
- Optimized the data management for historical data - Optimized the data management for historical data
- Optimized the exchange rate service - Optimized the exchange rate service
- Improved the user table of the admin control panel - Improved the users table of the admin control panel
### Fixed ### Fixed

View File

@ -7,16 +7,15 @@
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p> </p>
<p> <p>
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow"> <a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/> <img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
</a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow"> <a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/> <img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</a>
</p> </p>
</div> </div>
**Ghostfolio** is an open source portfolio tracker. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules. **Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
## Why Ghostfolio? ## Why Ghostfolio?
@ -43,10 +42,13 @@ Ghostfolio is for you if you are...
## Features ## Features
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`) - ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Dark Mode - ✅ Dark Mode
- ✅ Zen Mode
- ✅ Mobile-first design
## Technology Stack ## Technology Stack
@ -54,11 +56,11 @@ Ghostfolio is a modern web application written in [TypeScript](https://www.types
### Backend ### Backend
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database and [Redis](https://redis.io) for caching. The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database together with [Prisma](https://www.prisma.io) and [Redis](https://redis.io) for caching.
### Frontend ### Frontend
The frontend is built with [Angular](https://angular.io). The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Getting Started ## Getting Started
@ -86,12 +88,14 @@ Please make sure you have completed the instructions from [_Setup_](#Setup)
### Start server ### Start server
- Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_ <ol type="a">
- Serve: Run `yarn start:server` <li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
<li>Serve: Run <code>yarn start:server</code></li>
</ol>
### Start client ### Start client
- Run `yarn start:client` Run `yarn start:client`
## Testing ## Testing

View File

@ -86,7 +86,6 @@
"main": "apps/client/src/main.ts", "main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts", "polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json", "tsConfig": "apps/client/tsconfig.app.json",
"aot": true,
"assets": [ "assets": [
"apps/client/src/assets", "apps/client/src/assets",
{ {
@ -121,7 +120,13 @@
} }
], ],
"styles": ["apps/client/src/styles.scss"], "styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"] "scripts": ["node_modules/marked/lib/marked.js"],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -152,7 +157,8 @@
] ]
} }
}, },
"outputs": ["{options.outputPath}"] "outputs": ["{options.outputPath}"],
"defaultConfiguration": ""
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",

View File

@ -108,7 +108,7 @@ export class AdminService {
createdAt: true, createdAt: true,
id: true id: true
}, },
take: 20, take: 30,
where: { where: {
NOT: { NOT: {
Analytics: null Analytics: null

View File

@ -1,5 +1,6 @@
import { join } from 'path'; import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -34,6 +35,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AuthDeviceModule,
AuthModule, AuthModule,
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),

View File

@ -0,0 +1,44 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('auth-device')
export class AuthDeviceController {
public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAuthDevice
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id });
}
}

View File

@ -0,0 +1,4 @@
export interface AuthDeviceDto {
createdAt: string;
id: string;
}

View File

@ -0,0 +1,18 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [AuthDeviceController],
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
})
],
providers: [AuthDeviceService, ConfigurationService, PrismaService]
})
export class AuthDeviceModule {}

View File

@ -0,0 +1,65 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { AuthDevice, Prisma } from '@prisma/client';
@Injectable()
export class AuthDeviceService {
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
) {}
public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice | null> {
return this.prisma.authDevice.findUnique({
where
});
}
public async authDevices(params: {
skip?: number;
take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput;
}): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.authDevice.findMany({
skip,
take,
cursor,
where,
orderBy
});
}
public async createAuthDevice(
data: Prisma.AuthDeviceCreateInput
): Promise<AuthDevice> {
return this.prisma.authDevice.create({
data
});
}
public async updateAuthDevice(params: {
data: Prisma.AuthDeviceUpdateInput;
where: Prisma.AuthDeviceWhereUniqueInput;
}): Promise<AuthDevice> {
const { data, where } = params;
return this.prisma.authDevice.update({
data,
where
});
}
public async deleteAuthDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice> {
return this.prisma.authDevice.delete({
where
});
}
}

View File

@ -1,9 +1,12 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { import {
Body,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Param, Param,
Post,
Req, Req,
Res, Res,
UseGuards UseGuards
@ -12,12 +15,17 @@ import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly webAuthService: WebAuthService
) {} ) {}
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
@ -53,4 +61,44 @@ export class AuthController {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`); res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
} }
} }
@Get('webauthn/generate-attestation-options')
@UseGuards(AuthGuard('jwt'))
public async generateAttestationOptions() {
return this.webAuthService.generateAttestationOptions();
}
@Post('webauthn/verify-attestation')
@UseGuards(AuthGuard('jwt'))
public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) {
return this.webAuthService.verifyAttestation(
body.deviceName,
body.credential
);
}
@Post('webauthn/generate-assertion-options')
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
return this.webAuthService.generateAssertionOptions(body.deviceId);
}
@Post('webauthn/verify-assertion')
public async verifyAssertion(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) {
try {
const authToken = await this.webAuthService.verifyAssertion(
body.deviceId,
body.credential
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
} }

View File

@ -1,3 +1,5 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -18,12 +20,14 @@ import { JwtStrategy } from './jwt.strategy';
}) })
], ],
providers: [ providers: [
AuthDeviceService,
AuthService, AuthService,
ConfigurationService, ConfigurationService,
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
PrismaService, PrismaService,
UserService UserService,
WebAuthService
] ]
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,5 +1,10 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto;
}
export interface ValidateOAuthLoginParams { export interface ValidateOAuthLoginParams {
provider: Provider; provider: Provider;
thirdPartyId: string; thirdPartyId: string;

View File

@ -0,0 +1,226 @@
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
readonly authenticatorData: ArrayBuffer;
readonly signature: ArrayBuffer;
readonly userHandle: ArrayBuffer | null;
}
export interface AuthenticatorAttestationResponse
extends AuthenticatorResponse {
readonly attestationObject: ArrayBuffer;
}
export interface AuthenticationExtensionsClientInputs {
appid?: string;
appidExclude?: string;
credProps?: boolean;
uvm?: boolean;
}
export interface AuthenticationExtensionsClientOutputs {
appid?: boolean;
credProps?: CredentialPropertiesOutput;
uvm?: UvmEntries;
}
export interface AuthenticatorSelectionCriteria {
authenticatorAttachment?: AuthenticatorAttachment;
requireResidentKey?: boolean;
residentKey?: ResidentKeyRequirement;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredential extends Credential {
readonly rawId: ArrayBuffer;
readonly response: AuthenticatorResponse;
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
}
export interface PublicKeyCredentialCreationOptions {
attestation?: AttestationConveyancePreference;
authenticatorSelection?: AuthenticatorSelectionCriteria;
challenge: BufferSource;
excludeCredentials?: PublicKeyCredentialDescriptor[];
extensions?: AuthenticationExtensionsClientInputs;
pubKeyCredParams: PublicKeyCredentialParameters[];
rp: PublicKeyCredentialRpEntity;
timeout?: number;
user: PublicKeyCredentialUserEntity;
}
export interface PublicKeyCredentialDescriptor {
id: BufferSource;
transports?: AuthenticatorTransport[];
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialParameters {
alg: COSEAlgorithmIdentifier;
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialRequestOptions {
allowCredentials?: PublicKeyCredentialDescriptor[];
challenge: BufferSource;
extensions?: AuthenticationExtensionsClientInputs;
rpId?: string;
timeout?: number;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredentialUserEntity
extends PublicKeyCredentialEntity {
displayName: string;
id: BufferSource;
}
export interface AuthenticatorResponse {
readonly clientDataJSON: ArrayBuffer;
}
export interface CredentialPropertiesOutput {
rk?: boolean;
}
export interface Credential {
readonly id: string;
readonly type: string;
}
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
id?: string;
}
export interface PublicKeyCredentialEntity {
name: string;
}
export declare type AttestationConveyancePreference =
| 'direct'
| 'enterprise'
| 'indirect'
| 'none';
export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb';
export declare type COSEAlgorithmIdentifier = number;
export declare type UserVerificationRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type UvmEntries = UvmEntry[];
export declare type AuthenticatorAttachment = 'cross-platform' | 'platform';
export declare type ResidentKeyRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type BufferSource = ArrayBufferView | ArrayBuffer;
export declare type PublicKeyCredentialType = 'public-key';
export declare type UvmEntry = number[];
export interface PublicKeyCredentialCreationOptionsJSON
extends Omit<
PublicKeyCredentialCreationOptions,
'challenge' | 'user' | 'excludeCredentials'
> {
user: PublicKeyCredentialUserEntityJSON;
challenge: Base64URLString;
excludeCredentials: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs;
}
/**
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
* (eventually) get passed into navigator.credentials.get(...) in the browser.
*/
export interface PublicKeyCredentialRequestOptionsJSON
extends Omit<
PublicKeyCredentialRequestOptions,
'challenge' | 'allowCredentials'
> {
challenge: Base64URLString;
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs;
}
export interface PublicKeyCredentialDescriptorJSON
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
id: Base64URLString;
}
export interface PublicKeyCredentialUserEntityJSON
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
id: string;
}
/**
* The value returned from navigator.credentials.create()
*/
export interface AttestationCredential extends PublicKeyCredential {
response: AuthenticatorAttestationResponseFuture;
}
/**
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AttestationCredentialJSON
extends Omit<
AttestationCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAttestationResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
transports?: AuthenticatorTransport[];
}
/**
* The value returned from navigator.credentials.get()
*/
export interface AssertionCredential extends PublicKeyCredential {
response: AuthenticatorAssertionResponse;
}
/**
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AssertionCredentialJSON
extends Omit<
AssertionCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAssertionResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
}
/**
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAttestationResponseJSON
extends Omit<
AuthenticatorAttestationResponseFuture,
'clientDataJSON' | 'attestationObject'
> {
clientDataJSON: Base64URLString;
attestationObject: Base64URLString;
}
/**
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAssertionResponseJSON
extends Omit<
AuthenticatorAssertionResponse,
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
> {
authenticatorData: Base64URLString;
clientDataJSON: Base64URLString;
signature: Base64URLString;
userHandle?: string;
}
/**
* A WebAuthn-compatible device and the information needed to verify assertions by it
*/
export declare type AuthenticatorDevice = {
credentialPublicKey: Buffer;
credentialID: Buffer;
counter: number;
transports?: AuthenticatorTransport[];
};
/**
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
*/
export declare type Base64URLString = string;
/**
* AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7).
* Maintain an augmented version here so we can implement additional properties as the WebAuthn
* spec evolves.
*
* See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse
*
* Properties marked optional are not supported in all browsers.
*/
export interface AuthenticatorAttestationResponseFuture
extends AuthenticatorAttestationResponse {
getTransports?: () => AuthenticatorTransport[];
getAuthenticatorData?: () => ArrayBuffer;
getPublicKey?: () => ArrayBuffer;
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
}

View File

@ -0,0 +1,216 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Inject,
Injectable,
InternalServerErrorException
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import {
GenerateAssertionOptionsOpts,
GenerateAttestationOptionsOpts,
VerifiedAssertion,
VerifiedAttestation,
VerifyAssertionResponseOpts,
VerifyAttestationResponseOpts,
generateAssertionOptions,
generateAttestationOptions,
verifyAssertionResponse,
verifyAttestationResponse
} from '@simplewebauthn/server';
import { UserService } from '../user/user.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Injectable()
export class WebAuthService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService,
private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
get rpID() {
return this.configurationService.get('WEB_AUTH_RP_ID');
}
get expectedOrigin() {
return this.configurationService.get('ROOT_URL');
}
public async generateAttestationOptions() {
const user = this.request.user;
const opts: GenerateAttestationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: user.alias,
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: false,
userVerification: 'required'
}
};
const options = generateAttestationOptions(opts);
await this.userService.updateUser({
data: {
authChallenge: options.challenge
},
where: {
id: user.id
}
});
return options;
}
public async verifyAttestation(
deviceName: string,
credential: AttestationCredentialJSON
): Promise<AuthDeviceDto> {
const user = this.request.user;
const expectedChallenge = user.authChallenge;
let verification: VerifiedAttestation;
try {
const opts: VerifyAttestationResponseOpts = {
credential,
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
};
verification = await verifyAttestationResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException(error.message);
}
const { verified, attestationInfo } = verification;
const devices = await this.deviceService.authDevices({
where: { userId: user.id }
});
if (verified && attestationInfo) {
const { credentialPublicKey, credentialID, counter } = attestationInfo;
let existingDevice = devices.find(
(device) => device.credentialId === credentialID
);
if (!existingDevice) {
/**
* Add the returned device to the user's list of devices
*/
existingDevice = await this.deviceService.createAuthDevice({
credentialPublicKey,
credentialId: credentialID,
counter,
User: { connect: { id: user.id } }
});
}
return {
createdAt: existingDevice.createdAt.toISOString(),
id: existingDevice.id
};
}
throw new InternalServerErrorException('An unknown error occurred');
}
public async generateAssertionOptions(deviceId: string) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) {
throw new Error('Device not found');
}
const opts: GenerateAssertionOptionsOpts = {
timeout: 60000,
allowCredentials: [
{
id: device.credentialId,
type: 'public-key',
transports: ['internal']
}
],
userVerification: 'preferred',
rpID: this.rpID
};
const options = generateAssertionOptions(opts);
await this.userService.updateUser({
data: {
authChallenge: options.challenge
},
where: {
id: device.userId
}
});
return options;
}
public async verifyAssertion(
deviceId: string,
credential: AssertionCredentialJSON
) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) {
throw new Error('Device not found');
}
const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion;
try {
const opts: VerifyAssertionResponseOpts = {
credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
counter: device.counter
}
};
verification = verifyAssertionResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException({ error: error.message });
}
const { verified, assertionInfo } = verification;
if (verified) {
device.counter = assertionInfo.newCounter;
await this.deviceService.updateAuthDevice({
data: device,
where: { id: device.id }
});
return this.jwtService.sign({
id: user.id
});
}
throw new Error();
}
}

View File

@ -37,7 +37,9 @@ export class ExperimentalController {
); );
} }
return benchmarks; return benchmarks.map(({ symbol }) => {
return symbol;
});
} }
@Get('benchmarks/:symbol') @Get('benchmarks/:symbol')

View File

@ -44,6 +44,7 @@ export class ExperimentalService {
fee: 0, fee: 0,
id: undefined, id: undefined,
platformId: undefined, platformId: undefined,
symbolProfileId: undefined,
type: Type.BUY, type: Type.BUY,
updatedAt: undefined, updatedAt: undefined,
userId: undefined userId: undefined

View File

@ -5,6 +5,8 @@ import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import * as bent from 'bent';
import { subDays } from 'date-fns';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -28,6 +30,10 @@ export class InfoService {
globalPermissions.push(permissions.enableSocialLogin); globalPermissions.push(permissions.enableSocialLogin);
} }
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
globalPermissions.push(permissions.enableStatistics);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription); globalPermissions.push(permissions.enableSubscription);
} }
@ -37,7 +43,8 @@ export class InfoService {
platforms, platforms,
currencies: Object.values(Currency), currencies: Object.values(Currency),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering() lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics()
}; };
} }
@ -54,4 +61,67 @@ export class InfoService {
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null; return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
} }
private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined;
}
const activeUsers1d = await this.countActiveUsers(1);
const activeUsers30d = await this.countActiveUsers(30);
const gitHubStargazers = await this.countGitHubStargazers();
return {
activeUsers1d,
activeUsers30d,
gitHubStargazers
};
}
private async countActiveUsers(aDays: number) {
return await this.prisma.user.count({
orderBy: {
Analytics: {
updatedAt: 'desc'
}
},
where: {
AND: [
{
NOT: {
Analytics: null
}
},
{
Analytics: {
updatedAt: {
gt: subDays(new Date(), aDays)
}
}
}
]
}
});
}
private async countGitHubStargazers(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio`,
'GET',
'json',
200,
{
'User-Agent': 'request'
}
);
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
console.error(error);
return undefined;
}
}
} }

View File

@ -132,12 +132,26 @@ export class OrderController {
return this.orderService.createOrder( return this.orderService.createOrder(
{ {
...data, ...data,
date,
Account: { Account: {
connect: { connect: {
id_userId: { id: accountId, userId: this.request.user.id } id_userId: { id: accountId, userId: this.request.user.id }
} }
}, },
date,
SymbolProfile: {
connectOrCreate: {
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id

View File

@ -2,7 +2,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Order, Prisma } from '@prisma/client'; import { DataSource, Order, Prisma } from '@prisma/client';
import { CacheService } from '../cache/cache.service'; import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service'; import { RedisCacheService } from '../redis-cache/redis-cache.service';
@ -53,6 +53,7 @@ export class OrderService {
// Gather symbol data of order in the background // Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: data.dataSource,
date: <Date>data.date, date: <Date>data.date,
symbol: data.symbol symbol: data.symbol
} }
@ -90,6 +91,7 @@ export class OrderService {
// Gather symbol data of order in the background // Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: <DataSource>data.dataSource,
date: <Date>data.date, date: <Date>data.date,
symbol: <string>data.symbol symbol: <string>data.symbol
} }

View File

@ -315,18 +315,6 @@ export class PortfolioController {
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
); );
let report = await portfolio.getReport(); return await portfolio.getReport();
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
// TODO: Filter out absolute numbers
}
return report;
} }
} }

View File

@ -11,6 +11,7 @@ import {
import { DateRange, RequestWithUser } from '@ghostfolio/common/types'; import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { DataSource } from '@prisma/client';
import { import {
add, add,
format, format,
@ -19,6 +20,7 @@ import {
getYear, getYear,
isAfter, isAfter,
isSameDay, isSameDay,
parse,
parseISO, parseISO,
setDate, setDate,
setMonth, setMonth,
@ -74,7 +76,8 @@ export class PortfolioService {
// Get portfolio from database // Get portfolio from database
const orders = await this.orderService.orders({ const orders = await this.orderService.orders({
include: { include: {
Account: true Account: true,
SymbolProfile: true
}, },
orderBy: { date: 'asc' }, orderBy: { date: 'asc' },
where: { userId: aUserId } where: { userId: aUserId }
@ -158,8 +161,8 @@ export class PortfolioService {
return { return {
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'), date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
grossPerformancePercent: portfolioItem.grossPerformancePercent, grossPerformancePercent: portfolioItem.grossPerformancePercent,
marketPrice: portfolioItem.value || null, marketPrice: portfolioItem.value ?? null,
value: portfolioItem.value || null value: portfolioItem.value - portfolioItem.investment ?? null
}; };
}); });
} }
@ -214,6 +217,8 @@ export class PortfolioService {
transactionCount transactionCount
} = portfolio.getPositions(new Date())[aSymbol]; } = portfolio.getPositions(new Date())[aSymbol];
const orders = portfolio.getOrders(aSymbol);
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[aSymbol], [aSymbol],
'day', 'day',
@ -226,6 +231,7 @@ export class PortfolioService {
} }
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
let currentAveragePrice: number;
let maxPrice = marketPrice; let maxPrice = marketPrice;
let minPrice = marketPrice; let minPrice = marketPrice;
@ -233,9 +239,24 @@ export class PortfolioService {
for (const [date, { marketPrice }] of Object.entries( for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol] historicalData[aSymbol]
)) { )) {
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
if (
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
) {
// Get snapshot of first day of month
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
.positions[aSymbol];
orders.shift();
if (snapshot?.averagePrice) {
currentAveragePrice = snapshot?.averagePrice;
}
}
historicalDataArray.push({ historicalDataArray.push({
averagePrice,
date, date,
averagePrice: currentAveragePrice,
value: marketPrice value: marketPrice
}); });
@ -289,7 +310,7 @@ export class PortfolioService {
if (isEmpty(historicalData)) { if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw( historicalData = await this.dataProviderService.getHistoricalRaw(
[aSymbol], [{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
portfolio.getMinDate(), portfolio.getMinDate(),
new Date() new Date()
); );

View File

@ -4,9 +4,10 @@ import { locale } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import { getPermissions, permissions } from '@ghostfolio/common/permissions'; import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { add } from 'date-fns'; import { add, isBefore } from 'date-fns';
const crypto = require('crypto'); const crypto = require('crypto');
@ -24,7 +25,8 @@ export class UserService {
alias, alias,
id, id,
role, role,
Settings Settings,
subscription
}: UserWithSettings): Promise<IUser> { }: UserWithSettings): Promise<IUser> {
const access = await this.prisma.access.findMany({ const access = await this.prisma.access.findMany({
include: { include: {
@ -43,6 +45,7 @@ export class UserService {
return { return {
alias, alias,
id, id,
subscription,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.User.alias, alias: accessItem.User.alias,
@ -54,11 +57,7 @@ export class UserService {
settings: { settings: {
locale, locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
viewMode: Settings.viewMode ?? ViewMode.DEFAULT viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
},
subscription: {
expiresAt: resetHours(add(new Date(), { days: 7 })),
type: 'Trial'
} }
}; };
} }
@ -66,26 +65,49 @@ export class UserService {
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
const user = await this.prisma.user.findUnique({ const userFromDatabase = await this.prisma.user.findUnique({
include: { Account: true, Settings: true }, include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput where: userWhereUniqueInput
}); });
if (user?.Settings) { const user: UserWithSettings = userFromDatabase;
if (!user.Settings.currency) {
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed // Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY; userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
} }
} else if (user) { } else if (userFromDatabase) {
// Set default settings if needed // Set default settings if needed
user.Settings = { userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY, currency: UserService.DEFAULT_CURRENCY,
updatedAt: new Date(), updatedAt: new Date(),
userId: user?.id, userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT viewMode: ViewMode.DEFAULT
}; };
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (userFromDatabase?.Subscription?.length > 0) {
const latestSubscription = userFromDatabase.Subscription.reduce(
(a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}
);
user.subscription = {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
user.subscription = {
type: SubscriptionType.Basic
};
}
}
return user; return user;
} }

View File

@ -1,4 +1,4 @@
import { Account, Currency, Platform } from '@prisma/client'; import { Account, Currency, Platform, SymbolProfile } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
@ -12,6 +12,7 @@ export class Order {
private id: string; private id: string;
private quantity: number; private quantity: number;
private symbol: string; private symbol: string;
private symbolProfile: SymbolProfile;
private total: number; private total: number;
private type: OrderType; private type: OrderType;
private unitPrice: number; private unitPrice: number;
@ -24,6 +25,7 @@ export class Order {
this.id = data.id || uuidv4(); this.id = data.id || uuidv4();
this.quantity = data.quantity; this.quantity = data.quantity;
this.symbol = data.symbol; this.symbol = data.symbol;
this.symbolProfile = data.symbolProfile;
this.type = data.type; this.type = data.type;
this.unitPrice = data.unitPrice; this.unitPrice = data.unitPrice;
@ -58,6 +60,10 @@ export class Order {
return this.symbol; return this.symbol;
} }
getSymbolProfile() {
return this.symbolProfile;
}
public getTotal() { public getTotal() {
return this.total; return this.total;
} }

View File

@ -120,6 +120,7 @@ describe('Portfolio', () => {
} }
], ],
alias: 'Test', alias: 'Test',
authChallenge: null,
createdAt: new Date(), createdAt: new Date(),
id: USER_ID, id: USER_ID,
provider: null, provider: null,
@ -189,6 +190,7 @@ describe('Portfolio', () => {
id: '8d999347-dee2-46ee-88e1-26b344e71fcc', id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
quantity: 1, quantity: 1,
symbol: 'BTCUSD', symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY, type: Type.BUY,
unitPrice: 49631.24, unitPrice: 49631.24,
updatedAt: null, updatedAt: null,
@ -223,6 +225,7 @@ describe('Portfolio', () => {
}, },
allocationCurrent: 1, allocationCurrent: 1,
allocationInvestment: 1, allocationInvestment: 1,
countries: [],
currency: Currency.USD, currency: Currency.USD,
exchange: UNKNOWN_KEY, exchange: UNKNOWN_KEY,
grossPerformance: 0, grossPerformance: 0,
@ -290,6 +293,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY, type: Type.BUY,
unitPrice: 991.49, unitPrice: 991.49,
updatedAt: null, updatedAt: null,
@ -324,6 +328,7 @@ describe('Portfolio', () => {
}, },
// allocationCurrent: 1, // allocationCurrent: 1,
allocationInvestment: 1, allocationInvestment: 1,
countries: [],
currency: Currency.USD, currency: Currency.USD,
exchange: UNKNOWN_KEY, exchange: UNKNOWN_KEY,
// grossPerformance: 0, // grossPerformance: 0,
@ -385,6 +390,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY, type: Type.BUY,
unitPrice: 991.49, unitPrice: 991.49,
updatedAt: null, updatedAt: null,
@ -401,6 +407,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.3, quantity: 0.3,
symbol: 'ETHUSD', symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY, type: Type.BUY,
unitPrice: 1050, unitPrice: 1050,
updatedAt: null, updatedAt: null,
@ -461,6 +468,7 @@ describe('Portfolio', () => {
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475', id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
quantity: 0.05614682, quantity: 0.05614682,
symbol: 'BTCUSD', symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY, type: Type.BUY,
unitPrice: 3562.089535970158, unitPrice: 3562.089535970158,
updatedAt: null, updatedAt: null,
@ -477,6 +485,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY, type: Type.BUY,
unitPrice: 991.49, unitPrice: 991.49,
updatedAt: null, updatedAt: null,
@ -550,6 +559,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY, type: Type.BUY,
unitPrice: 991.49, unitPrice: 991.49,
updatedAt: null, updatedAt: null,
@ -566,6 +576,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.1, quantity: 0.1,
symbol: 'ETHUSD', symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.SELL, type: Type.SELL,
unitPrice: 1050, unitPrice: 1050,
updatedAt: null, updatedAt: null,
@ -582,6 +593,7 @@ describe('Portfolio', () => {
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.2, quantity: 0.2,
symbol: 'ETHUSD', symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY, type: Type.BUY,
unitPrice: 1050, unitPrice: 1050,
updatedAt: null, updatedAt: null,

View File

@ -8,7 +8,11 @@ import {
Position, Position,
UserWithSettings UserWithSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types'; import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import { import {
add, add,
format, format,
@ -127,6 +131,7 @@ export class Portfolio implements PortfolioInterface {
id, id,
quantity, quantity,
symbol, symbol,
symbolProfile,
type, type,
unitPrice unitPrice
}) => { }) => {
@ -139,6 +144,7 @@ export class Portfolio implements PortfolioInterface {
id, id,
quantity, quantity,
symbol, symbol,
symbolProfile,
type, type,
unitPrice unitPrice
}) })
@ -204,6 +210,8 @@ export class Portfolio implements PortfolioInterface {
symbols.forEach((symbol) => { symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {}; const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[];
let sectorsOfSymbol: Sector[];
const [portfolioItem] = portfolioItems; const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => { const ordersBySymbol = this.getOrders().filter((order) => {
@ -243,6 +251,32 @@ export class Portfolio implements PortfolioInterface {
original: originalValueOfSymbol original: originalValueOfSymbol
}; };
} }
countriesOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
[]
).map((country) => {
const { code, weight } = country as Prisma.JsonObject;
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
});
sectorsOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.sectors as Prisma.JsonArray) ?? []
).map((sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
weight: weight as number
};
});
}); });
let now = portfolioItemsNow.positions[symbol].marketPrice; let now = portfolioItemsNow.positions[symbol].marketPrice;
@ -289,6 +323,7 @@ export class Portfolio implements PortfolioInterface {
) / value, ) / value,
allocationInvestment: allocationInvestment:
portfolioItem.positions[symbol].investment / investment, portfolioItem.positions[symbol].investment / investment,
countries: countriesOfSymbol,
grossPerformance: roundTo( grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before), portfolioItemsNow.positions[symbol].quantity * (now - before),
2 2
@ -296,7 +331,13 @@ export class Portfolio implements PortfolioInterface {
grossPerformancePercent: roundTo((now - before) / before, 4), grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment, investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity, quantity: portfolioItem.positions[symbol].quantity,
transactionCount: portfolioItem.positions[symbol].transactionCount sectors: sectorsOfSymbol,
transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
)
}; };
}); });
@ -402,10 +443,10 @@ export class Portfolio implements PortfolioInterface {
accountClusterRisk: await this.rulesService.evaluate( accountClusterRisk: await this.rulesService.evaluate(
this, this,
[ [
new AccountClusterRiskCurrentInvestment( new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService this.exchangeRateDataService
), ),
new AccountClusterRiskInitialInvestment( new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService this.exchangeRateDataService
), ),
new AccountClusterRiskSingleAccount(this.exchangeRateDataService) new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
@ -486,7 +527,13 @@ export class Portfolio implements PortfolioInterface {
.reduce((previous, current) => previous + current, 0); .reduce((previous, current) => previous + current, 0);
} }
public getOrders() { public getOrders(aSymbol?: string) {
if (aSymbol) {
return this.orders.filter((order) => {
return order.getSymbol() === aSymbol;
});
}
return this.orders; return this.orders;
} }
@ -538,6 +585,7 @@ export class Portfolio implements PortfolioInterface {
fee: order.fee, fee: order.fee,
quantity: order.quantity, quantity: order.quantity,
symbol: order.symbol, symbol: order.symbol,
symbolProfile: order.SymbolProfile,
type: <OrderType>order.type, type: <OrderType>order.type,
unitPrice: order.unitPrice unitPrice: order.unitPrice
}) })

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@ -17,6 +17,7 @@ export class ConfigurationService {
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
@ -26,7 +27,8 @@ export class ConfigurationService {
RAKUTEN_RAPID_API_KEY: str({ default: '' }), RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }) ROOT_URL: str({ default: 'http://localhost:4200' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
}); });
} }

View File

@ -5,6 +5,7 @@ import {
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
format, format,
@ -18,6 +19,7 @@ import {
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider.service'; import { DataProviderService } from './data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
@ -115,15 +117,13 @@ export class DataGatheringService {
} }
} }
public async gatherSymbols( public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
aSymbolsWithStartDate: { date: Date; symbol: string }[]
) {
let hasError = false; let hasError = false;
for (const { date, symbol } of aSymbolsWithStartDate) { for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw(
[symbol], [{ dataSource, symbol }],
date, date,
new Date() new Date()
); );
@ -184,20 +184,24 @@ export class DataGatheringService {
} }
} }
public async getCustomSymbolsToGather(startDate?: Date) { public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations(); const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => { return scraperConfigurations.map((scraperConfiguration) => {
return { return {
dataSource: DataSource.GHOSTFOLIO,
date: startDate, date: startDate,
symbol: scraperConfiguration.symbol symbol: scraperConfiguration.symbol
}; };
}); });
} }
private getBenchmarksToGather(startDate: Date) { private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather = benchmarks.map((symbol) => { const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
return { return {
dataSource,
symbol, symbol,
date: startDate date: startDate
}; };
@ -205,6 +209,7 @@ export class DataGatheringService {
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
benchmarksToGather.push({ benchmarksToGather.push({
dataSource: DataSource.RAKUTEN,
date: startDate, date: startDate,
symbol: 'GF.FEAR_AND_GREED_INDEX' symbol: 'GF.FEAR_AND_GREED_INDEX'
}); });
@ -213,16 +218,16 @@ export class DataGatheringService {
return benchmarksToGather; return benchmarksToGather;
} }
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prisma.order.findMany({ const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { symbol: true } select: { dataSource: true, symbol: true }
}); });
const distinctOrdersWithDate = distinctOrders const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
.filter((distinctOrder) => { .filter((distinctOrder) => {
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol); return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
}) })
@ -233,12 +238,15 @@ export class DataGatheringService {
}; };
}); });
const currencyPairsToGather = currencyPairs.map((symbol) => { const currencyPairsToGather = currencyPairs.map(
return { ({ dataSource, symbol }) => {
symbol, return {
date: startDate dataSource,
}; symbol,
}); date: startDate
};
}
);
const customSymbolsToGather = await this.getCustomSymbolsToGather( const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate startDate
@ -252,24 +260,27 @@ export class DataGatheringService {
]; ];
} }
private async getSymbolsMax() { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate = new Date(getUtc('2015-01-01')); const startDate = new Date(getUtc('2015-01-01'));
const customSymbolsToGather = await this.getCustomSymbolsToGather( const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate startDate
); );
const currencyPairsToGather = currencyPairs.map((symbol) => { const currencyPairsToGather = currencyPairs.map(
return { ({ dataSource, symbol }) => {
symbol, return {
date: startDate dataSource,
}; symbol,
}); date: startDate
};
}
);
const distinctOrders = await this.prisma.order.findMany({ const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true, symbol: true } select: { dataSource: true, date: true, symbol: true }
}); });
return [ return [

View File

@ -1,6 +1,4 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { import {
isCrypto,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -14,15 +12,15 @@ import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service'; import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
import { DataProviderInterface } from './interfaces/data-provider.interface';
import { import {
IDataGatheringItem,
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from './interfaces/interfaces'; } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
export class DataProviderService implements DataProviderInterface { export class DataProviderService {
public constructor( public constructor(
private readonly alphaVantageService: AlphaVantageService, private readonly alphaVantageService: AlphaVantageService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -121,79 +119,53 @@ export class DataProviderService implements DataProviderInterface {
} }
public async getHistoricalRaw( public async getHistoricalRaw(
aSymbols: string[], aDataGatheringItems: IDataGatheringItem[],
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
const filteredSymbols = aSymbols.filter((symbol) => { const result: {
return !isGhostfolioScraperApiSymbol(symbol); [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}); } = {};
const dataOfYahoo = await this.yahooFinanceService.getHistorical( const promises: Promise<{
filteredSymbols, data: { [date: string]: IDataProviderHistoricalResponse };
undefined, symbol: string;
from, }>[] = [];
to for (const { dataSource, symbol } of aDataGatheringItems) {
); const dataProvider = this.getDataProvider(dataSource);
if (dataProvider.canHandle(symbol)) {
if (aSymbols.length === 1) { promises.push(
const symbol = aSymbols[0]; dataProvider
.getHistorical([symbol], undefined, from, to)
if ( .then((data) => ({ data: data?.[symbol], symbol }))
isCrypto(symbol) &&
this.configurationService.get('ALPHA_VANTAGE_API_KEY')
) {
// Merge data from Yahoo with data from Alpha Vantage
const dataOfAlphaVantage = await this.alphaVantageService.getHistorical(
[symbol],
undefined,
from,
to
); );
return {
[symbol]: {
...dataOfYahoo[symbol],
...dataOfAlphaVantage[symbol]
}
};
} else if (isGhostfolioScraperApiSymbol(symbol)) {
const dataOfGhostfolioScraperApi = await this.ghostfolioScraperApiService.getHistorical(
[symbol],
undefined,
from,
to
);
return dataOfGhostfolioScraperApi;
} else if (
isRakutenRapidApiSymbol(symbol) &&
this.configurationService.get('RAKUTEN_RAPID_API_KEY')
) {
const dataOfRakutenRapidApi = await this.rakutenRapidApiService.getHistorical(
[symbol],
undefined,
from,
to
);
return dataOfRakutenRapidApi;
} }
} }
return dataOfYahoo; const allData = await Promise.all(promises);
for (const { data, symbol } of allData) {
result[symbol] = data;
}
return result;
} }
public async search(aSymbol: string) { public async search(aSymbol: string) {
return this.getDataProvider().search(aSymbol); return this.getDataProvider(
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
).search(aSymbol);
} }
private getDataProvider() { private getDataProvider(providerName: DataSource) {
switch (this.configurationService.get('DATA_SOURCES')[0]) { switch (providerName) {
case DataSource.ALPHA_VANTAGE: case DataSource.ALPHA_VANTAGE:
return this.alphaVantageService; return this.alphaVantageService;
case DataSource.GHOSTFOLIO:
return this.ghostfolioScraperApiService;
case DataSource.RAKUTEN:
return this.rakutenRapidApiService;
case DataSource.YAHOO: case DataSource.YAHOO:
return this.yahooFinanceService; return this.yahooFinanceService;
default: default:

View File

@ -24,6 +24,10 @@ export class AlphaVantageService implements DataProviderInterface {
}); });
} }
public canHandle(symbol: string) {
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {

View File

@ -1,4 +1,7 @@
import { getYesterday } from '@ghostfolio/common/helper'; import {
getYesterday,
isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -21,6 +24,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
public constructor(private prisma: PrismaService) {} public constructor(private prisma: PrismaService) {}
public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol);
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {

View File

@ -1,4 +1,8 @@
import { getToday, getYesterday } from '@ghostfolio/common/helper'; import {
getToday,
getYesterday,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -24,6 +28,13 @@ export class RakutenRapidApiService implements DataProviderInterface {
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) {} ) {}
public canHandle(symbol: string) {
return (
isRakutenRapidApiSymbol(symbol) &&
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
);
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {

View File

@ -12,9 +12,7 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
Industry,
MarketState, MarketState,
Sector,
Type Type
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { import {
@ -28,6 +26,10 @@ export class YahooFinanceService implements DataProviderInterface {
public constructor() {} public constructor() {}
public canHandle(symbol: string) {
return true;
}
public async get( public async get(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
@ -66,16 +68,6 @@ export class YahooFinanceService implements DataProviderInterface {
type: this.parseType(this.getType(symbol, value)) type: this.parseType(this.getType(symbol, value))
}; };
const industry = this.parseIndustry(value.summaryProfile?.industry);
if (industry) {
response[symbol].industry = industry;
}
const sector = this.parseSector(value.summaryProfile?.sector);
if (sector) {
response[symbol].sector = sector;
}
const url = value.summaryProfile?.website; const url = value.summaryProfile?.website;
if (url) { if (url) {
response[symbol].url = url; response[symbol].url = url;
@ -224,55 +216,6 @@ export class YahooFinanceService implements DataProviderInterface {
return aString; return aString;
} }
private parseIndustry(aString: string): Industry {
if (aString === undefined) {
return undefined;
}
if (aString?.toLowerCase() === 'auto manufacturers') {
return Industry.Automotive;
} else if (aString?.toLowerCase() === 'biotechnology') {
return Industry.Biotechnology;
} else if (
aString?.toLowerCase() === 'drug manufacturers—specialty & generic'
) {
return Industry.Pharmaceutical;
} else if (
aString?.toLowerCase() === 'internet content & information' ||
aString?.toLowerCase() === 'internet retail'
) {
return Industry.Internet;
} else if (aString?.toLowerCase() === 'packaged foods') {
return Industry.Food;
} else if (aString?.toLowerCase() === 'software—application') {
return Industry.Software;
}
return Industry.Unknown;
}
private parseSector(aString: string): Sector {
if (aString === undefined) {
return undefined;
}
if (
aString?.toLowerCase() === 'consumer cyclical' ||
aString?.toLowerCase() === 'consumer defensive'
) {
return Sector.Consumer;
} else if (aString?.toLowerCase() === 'healthcare') {
return Sector.Healthcare;
} else if (
aString?.toLowerCase() === 'communication services' ||
aString?.toLowerCase() === 'technology'
) {
return Sector.Technology;
}
return Sector.Unknown;
}
private parseType(aString: string): Type { private parseType(aString: string): Type {
if (aString?.toLowerCase() === 'cryptocurrency') { if (aString?.toLowerCase() === 'cryptocurrency') {
return Type.Cryptocurrency; return Type.Cryptocurrency;
@ -287,6 +230,6 @@ export class YahooFinanceService implements DataProviderInterface {
} }
export const convertFromYahooSymbol = (aSymbol: string) => { export const convertFromYahooSymbol = (aSymbol: string) => {
let symbol = aSymbol.replace('-', ''); const symbol = aSymbol.replace('-', '');
return symbol.replace('=X', ''); return symbol.replace('=X', '');
}; };

View File

@ -7,6 +7,8 @@ import {
} from './interfaces'; } from './interfaces';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean;
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>; get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
getHistorical( getHistorical(

View File

@ -8,6 +8,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
@ -18,4 +19,5 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PORT: number; REDIS_PORT: number;
ROOT_URL: string; ROOT_URL: string;
WEB_AUTH_RP_ID: string;
} }

View File

@ -1,31 +1,14 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Account, Currency, DataSource } from '@prisma/client'; import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
import { OrderType } from '../../models/order-type'; import { OrderType } from '../../models/order-type';
export const Industry = {
Automotive: 'Automotive',
Biotechnology: 'Biotechnology',
Food: 'Food',
Internet: 'Internet',
Pharmaceutical: 'Pharmaceutical',
Software: 'Software',
Unknown: UNKNOWN_KEY
};
export const MarketState = { export const MarketState = {
closed: 'closed', closed: 'closed',
delayed: 'delayed', delayed: 'delayed',
open: 'open' open: 'open'
}; };
export const Sector = {
Consumer: 'Consumer',
Healthcare: 'Healthcare',
Technology: 'Technology',
Unknown: UNKNOWN_KEY
};
export const Type = { export const Type = {
Cryptocurrency: 'Cryptocurrency', Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF', ETF: 'ETF',
@ -41,6 +24,7 @@ export interface IOrder {
id?: string; id?: string;
quantity: number; quantity: number;
symbol: string; symbol: string;
symbolProfile: SymbolProfile;
type: OrderType; type: OrderType;
unitPrice: number; unitPrice: number;
} }
@ -54,21 +38,21 @@ export interface IDataProviderResponse {
currency: Currency; currency: Currency;
dataSource: DataSource; dataSource: DataSource;
exchange?: string; exchange?: string;
industry?: Industry;
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
marketPrice: number; marketPrice: number;
marketState: MarketState; marketState: MarketState;
name: string; name: string;
sector?: Sector;
type?: Type; type?: Type;
url?: string; url?: string;
} }
export type Industry = typeof Industry[keyof typeof Industry]; export interface IDataGatheringItem {
dataSource: DataSource;
date?: Date;
symbol: string;
}
export type MarketState = typeof MarketState[keyof typeof MarketState]; export type MarketState = typeof MarketState[keyof typeof MarketState];
export type Sector = typeof Sector[keyof typeof Sector];
export type Type = typeof Type[keyof typeof Type]; export type Type = typeof Type[keyof typeof Type];

View File

@ -16,8 +16,8 @@ module.exports = {
}, },
coverageDirectory: '../../coverage/apps/client', coverageDirectory: '../../coverage/apps/client',
snapshotSerializers: [ snapshotSerializers: [
'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js', 'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/AngularSnapshotSerializer.js', 'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/HTMLCommentSerializer.js' 'jest-preset-angular/build/serializers/html-comment'
] ]
}; };

View File

@ -28,13 +28,6 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule) import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
}, },
{
path: 'analysis',
loadChildren: () =>
import('./pages/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{ {
path: 'auth', path: 'auth',
loadChildren: () => loadChildren: () =>
@ -53,10 +46,10 @@ const routes: Routes = [
) )
}, },
{ {
path: 'report', path: 'register',
loadChildren: () => loadChildren: () =>
import('./pages/report/report-page.module').then( import('./pages/register/register-page.module').then(
(m) => m.ReportPageModule (m) => m.RegisterPageModule
) )
}, },
{ {
@ -69,7 +62,28 @@ const routes: Routes = [
{ {
path: 'start', path: 'start',
loadChildren: () => loadChildren: () =>
import('./pages/login/login-page.module').then((m) => m.LoginPageModule) import('./pages/landing/landing-page.module').then(
(m) => m.LandingPageModule
)
},
{
path: 'tools',
loadChildren: () =>
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
},
{
path: 'tools/analysis',
loadChildren: () =>
import('./pages/tools/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'tools/report',
loadChildren: () =>
import('./pages/tools/report/report-page.module').then(
(m) => m.ReportPageModule
)
}, },
{ {
path: 'transactions', path: 'transactions',
@ -78,6 +92,13 @@ const routes: Routes = [
(m) => m.TransactionsPageModule (m) => m.TransactionsPageModule
) )
}, },
{
path: 'webauthn',
loadChildren: () =>
import('./pages/webauthn/webauthn-page.module').then(
(m) => m.WebauthnPageModule
)
},
{ {
path: 'zen', path: 'zen',
loadChildren: () => loadChildren: () =>

View File

@ -4,6 +4,7 @@
[currentRoute]="currentRoute" [currentRoute]="currentRoute"
[info]="info" [info]="info"
[user]="user" [user]="user"
(signOut)="onSignOut()"
></gf-header> ></gf-header>
</header> </header>
@ -11,13 +12,15 @@
<div *ngIf="canCreateAccount" class="container create-account-container"> <div *ngIf="canCreateAccount" class="container create-account-container">
<div class="row mb-5"> <div class="row mb-5">
<div class="col-md-6 offset-md-3"> <div class="col-md-6 offset-md-3">
<div <a [routerLink]="['/']">
class="create-account-box p-2 text-center" <mat-card
(click)="onCreateAccount()" class="create-account-box p-2 text-center"
(click)="onCreateAccount()"
>
<div class="mt-1" i18n>You are using the Live Demo.</div>
<button mat-button color="primary" i18n>Create Account</button>
</mat-card></a
> >
<div class="mt-1" i18n>You are using the Live Demo.</div>
<button mat-button color="primary" i18n>Create Account</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -25,7 +28,10 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<footer class="footer d-flex justify-content-center position-absolute w-100"> <footer
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
class="footer d-flex justify-content-center position-absolute w-100"
>
<div class="container text-center"> <div class="container text-center">
<div> <div>
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a> © {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>

View File

@ -5,8 +5,6 @@
padding: 5rem 0; padding: 5rem 0;
.create-account-box { .create-account-box {
border: 1px solid rgba(var(--palette-primary-500), 1);
border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
font-size: 90%; font-size: 90%;

View File

@ -10,12 +10,14 @@ import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MaterialCssVarsService } from 'angular-material-css-vars'; import { MaterialCssVarsService } from 'angular-material-css-vars';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
import { environment } from '../environments/environment'; import { environment } from '../environments/environment';
import { DataService } from './services/data.service'; import { DataService } from './services/data.service';
import { TokenStorageService } from './services/token-storage.service'; import { TokenStorageService } from './services/token-storage.service';
import { UserService } from './services/user/user.service';
@Component({ @Component({
selector: 'gf-root', selector: 'gf-root',
@ -27,25 +29,29 @@ export class AppComponent implements OnDestroy, OnInit {
public canCreateAccount: boolean; public canCreateAccount: boolean;
public currentRoute: string; public currentRoute: string;
public currentYear = new Date().getFullYear(); public currentYear = new Date().getFullYear();
public deviceType: string;
public info: InfoItem; public info: InfoItem;
public isLoggedIn = false;
public user: User; public user: User;
public version = environment.version; public version = environment.version;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService,
private materialCssVarsService: MaterialCssVarsService, private materialCssVarsService: MaterialCssVarsService,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService private tokenStorageService: TokenStorageService,
private userService: UserService
) { ) {
this.initializeTheme(); this.initializeTheme();
this.user = undefined; this.user = undefined;
} }
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => { this.dataService.fetchInfo().subscribe((info) => {
this.info = info; this.info = info;
}); });
@ -59,31 +65,28 @@ export class AppComponent implements OnDestroy, OnInit {
this.currentRoute = urlSegments[0].path; this.currentRoute = urlSegments[0].path;
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.isLoggedIn = !!this.tokenStorageService.getToken(); this.user = state.user;
if (this.isLoggedIn) { this.canCreateAccount = hasPermission(
this.dataService.fetchUser().subscribe((user) => { this.user?.permissions,
this.user = user; permissions.createUserAccount
);
this.canCreateAccount = hasPermission( this.changeDetectorRef.markForCheck();
this.user.permissions,
permissions.createUserAccount
);
this.cd.markForCheck();
});
} else {
this.user = null;
}
}); });
} }
public onCreateAccount() { public onCreateAccount() {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
}
public onSignOut() {
this.tokenStorageService.signOut();
this.userService.remove();
window.location.reload(); window.location.reload();
} }

View File

@ -2,6 +2,7 @@ import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http'; import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { import {
DateAdapter, DateAdapter,
MAT_DATE_FORMATS, MAT_DATE_FORMATS,
@ -34,6 +35,7 @@ import { LanguageService } from './core/language.service';
HttpClientModule, HttpClientModule,
MarkdownModule.forRoot(), MarkdownModule.forRoot(),
MatButtonModule, MatButtonModule,
MatCardModule,
MaterialCssVarsModule.forRoot({ MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme', darkThemeClass: 'is-dark-theme',
isAutoContrast: true, isAutoContrast: true,

View File

@ -1,24 +1,18 @@
<table <table class="gf-table w-100" mat-table [dataSource]="dataSource">
class="gf-table w-100"
matSort
matSortActive="account"
matSortDirection="desc"
mat-table
[dataSource]="dataSource"
>
<ng-container matColumnDef="account"> <ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
Name
</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }} {{ element.name }}
<span
*ngIf="element.isDefault"
class="d-lg-inline-block d-none text-muted"
>(Default)</span
>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="platform"> <ng-container matColumnDef="platform">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Platform</th>
Platform
</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex"> <div class="d-flex">
<gf-symbol-icon <gf-symbol-icon
@ -60,8 +54,10 @@
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Transactions</th> <th *matHeaderCellDef class="text-right" i18n mat-header-cell>
<td *matCellDef="let element" mat-cell> Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }} {{ element.Order?.length }}
</td> </td>
</ng-container> </ng-container>

View File

@ -6,13 +6,9 @@ import {
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit, OnInit,
Output, Output
ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Account as AccountModel } from '@prisma/client'; import { Account as AccountModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
@ -32,8 +28,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>(); @Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>(); @Output() accountToUpdate = new EventEmitter<AccountModel>();
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource(); public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
public displayedColumns = []; public displayedColumns = [];
public isLoading = true; public isLoading = true;
@ -41,11 +35,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor() {}
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router
) {}
public ngOnInit() {} public ngOnInit() {}
@ -60,7 +50,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
if (this.accounts) { if (this.accounts) {
this.dataSource = new MatTableDataSource(this.accounts); this.dataSource = new MatTableDataSource(this.accounts);
this.dataSource.sort = this.sort;
this.isLoading = false; this.isLoading = false;
} }

View File

@ -3,7 +3,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -22,7 +21,6 @@ import { AccountsTableComponent } from './accounts-table.component';
MatButtonModule, MatButtonModule,
MatInputModule, MatInputModule,
MatMenuModule, MatMenuModule,
MatSortModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
RouterModule RouterModule

View File

@ -8,9 +8,11 @@
class="d-none d-sm-block" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[color]=" [ngClass]="{
currentRoute === 'home' || currentRoute === 'zen' ? 'primary' : null 'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
" 'text-decoration-underline':
currentRoute === 'home' || currentRoute === 'zen'
}"
[routerLink]="['/']" [routerLink]="['/']"
>Overview</a >Overview</a
> >
@ -19,24 +21,27 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'analysis' ? 'primary' : null" [ngClass]="{
[routerLink]="['/analysis']" 'font-weight-bold':
>Analysis</a currentRoute === 'analysis' ||
> currentRoute === 'report' ||
<a currentRoute === 'tools',
*ngIf="user?.settings?.viewMode === 'DEFAULT'" 'text-decoration-underline':
class="d-none d-sm-block mx-1" currentRoute === 'analysis' ||
i18n currentRoute === 'report' ||
mat-flat-button currentRoute === 'tools'
[color]="currentRoute === 'report' ? 'primary' : null" }"
[routerLink]="['/report']" [routerLink]="['/tools']"
>X-ray</a >Tools</a
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'transactions' ? 'primary' : null" [ngClass]="{
'font-weight-bold': currentRoute === 'transactions',
'text-decoration-underline': currentRoute === 'transactions'
}"
[routerLink]="['/transactions']" [routerLink]="['/transactions']"
>Transactions</a >Transactions</a
> >
@ -44,7 +49,10 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'accounts' ? 'primary' : null" [ngClass]="{
'font-weight-bold': currentRoute === 'accounts',
'text-decoration-underline': currentRoute === 'accounts'
}"
[routerLink]="['/accounts']" [routerLink]="['/accounts']"
>Accounts</a >Accounts</a
> >
@ -53,7 +61,10 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'admin' ? 'primary' : null" [ngClass]="{
'font-weight-bold': currentRoute === 'admin',
'text-decoration-underline': currentRoute === 'admin'
}"
[routerLink]="['/admin']" [routerLink]="['/admin']"
>Admin Control</a >Admin Control</a
> >
@ -61,7 +72,10 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'resources' ? 'primary' : null" [ngClass]="{
'font-weight-bold': currentRoute === 'resources',
'text-decoration-underline': currentRoute === 'resources'
}"
[routerLink]="['/resources']" [routerLink]="['/resources']"
>Resources</a >Resources</a
> >
@ -70,7 +84,10 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null" [ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']" [routerLink]="['/pricing']"
>Pricing</a >Pricing</a
> >
@ -78,7 +95,10 @@
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null" [ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']" [routerLink]="['/about']"
>About</a >About</a
> >
@ -139,20 +159,18 @@
<hr class="m-0" /> <hr class="m-0" />
</ng-container> </ng-container>
<a <a
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }" [ngClass]="{
[routerLink]="['/analysis']" 'font-weight-bold':
>Analysis</a currentRoute === 'analysis' ||
> currentRoute === 'report' ||
<a currentRoute === 'tools'
class="d-block d-sm-none" }"
i18n [routerLink]="['/tools']"
mat-menu-item >Tools</a
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }"
[routerLink]="['/report']"
>X-ray</a
> >
<a <a
class="d-block d-sm-none" class="d-block d-sm-none"
@ -231,28 +249,44 @@
<gf-logo></gf-logo> <gf-logo></gf-logo>
</a> </a>
<span class="spacer"></span> <span class="spacer"></span>
<a
*ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[color]="currentRoute === 'pricing' ? 'primary' : null"
[routerLink]="['/pricing']"
>Pricing</a
>
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[color]="currentRoute === 'about' ? 'primary' : null" [ngClass]="{
'font-weight-bold': currentRoute === 'about',
'text-decoration-underline': currentRoute === 'about'
}"
[routerLink]="['/about']" [routerLink]="['/about']"
>About</a >About</a
> >
<a <a
class="d-none d-sm-block mx-1" *ngIf="hasPermissionForSubscription"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'pricing',
'text-decoration-underline': currentRoute === 'pricing'
}"
[routerLink]="['/pricing']"
>Pricing</a
>
<a
class="d-none d-sm-block mx-1 no-min-width px-1"
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio"
mat-flat-button mat-flat-button
>GitHub</a ><ion-icon name="logo-github"></ion-icon
> ></a>
<button i18n mat-flat-button (click)="openLoginDialog()">Sign in</button> <button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
Sign In
</button>
<a
class="d-none d-sm-block"
color="primary"
i18n
mat-flat-button
[routerLink]="['/register']"
>Get Started
</a>
</ng-container> </ng-container>
</mat-toolbar> </mat-toolbar>

View File

@ -1,14 +1,20 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter,
Input, Input,
OnChanges OnChanges,
Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component'; import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
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';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -26,6 +32,8 @@ export class HeaderComponent implements OnChanges {
@Input() info: InfoItem; @Input() info: InfoItem;
@Input() user: User; @Input() user: User;
@Output() signOut = new EventEmitter<void>();
public hasPermissionForSocialLogin: boolean; public hasPermissionForSocialLogin: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
@ -38,6 +46,7 @@ export class HeaderComponent implements OnChanges {
private dialog: MatDialog, private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService private tokenStorageService: TokenStorageService
) { ) {
this.impersonationStorageService this.impersonationStorageService
@ -75,8 +84,7 @@ export class HeaderComponent implements OnChanges {
} }
public onSignOut() { public onSignOut() {
this.tokenStorageService.signOut(); this.signOut.next();
window.location.reload();
} }
public openLoginDialog(): void { public openLoginDialog(): void {
@ -84,7 +92,8 @@ export class HeaderComponent implements OnChanges {
autoFocus: false, autoFocus: false,
data: { data: {
accessToken: '', accessToken: '',
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
title: 'Sign in'
}, },
width: '30rem' width: '30rem'
}); });
@ -109,7 +118,10 @@ export class HeaderComponent implements OnChanges {
} }
public setToken(aToken: string) { public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken); this.tokenStorageService.saveToken(
aToken,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
);
this.router.navigate(['/']); this.router.navigate(['/']);
} }

View File

@ -4,7 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.module'; import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfLogoModule } from '../logo/logo.module'; import { GfLogoModule } from '../logo/logo.module';
import { HeaderComponent } from './header.component'; import { HeaderComponent } from './header.component';

View File

@ -36,10 +36,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
public constructor() { public constructor() {
Chart.register( Chart.register(
LinearScale,
LineController, LineController,
LineElement, LineElement,
PointElement, PointElement,
LinearScale,
TimeScale TimeScale
); );
} }
@ -95,7 +95,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
responsive: true, responsive: true,
scales: { scales: {
x: { x: {
display: false, display: true,
grid: { grid: {
display: false display: false
}, },

View File

@ -0,0 +1,34 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
@Component({
selector: 'gf-login-with-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./login-with-access-token-dialog.scss'],
templateUrl: 'login-with-access-token-dialog.html'
})
export class LoginWithAccessTokenDialog {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
private settingsStorageService: SettingsStorageService
) {}
ngOnInit() {}
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
this.settingsStorageService.setSetting(
STAY_SIGNED_IN,
aValue.checked?.toString()
);
}
public onClose() {
this.dialogRef.close();
}
}

View File

@ -1,4 +1,9 @@
<h1 mat-dialog-title i18n>Sign in</h1> <gf-dialog-header
mat-dialog-title
[title]="data.title"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div mat-dialog-content> <div mat-dialog-content>
<div> <div>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin"> <ng-container *ngIf="data.hasPermissionToUseSocialLogin">
@ -21,15 +26,21 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="float-right" mat-dialog-actions> <div mat-dialog-actions>
<button i18n mat-flat-button [mat-dialog-close]="undefined">Cancel</button> <div class="flex-grow-1">
<button <mat-checkbox i18n (change)="onChangeStaySignedIn($event)"
color="primary" >Stay signed in</mat-checkbox
i18n >
mat-flat-button </div>
[disabled]="!data.accessToken" <div>
[mat-dialog-close]="data" <button
> color="primary"
Sign in i18n
</button> mat-flat-button
[disabled]="!data.accessToken"
[mat-dialog-close]="data"
>
Sign in
</button>
</div>
</div> </div>

View File

@ -3,10 +3,12 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, 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 { 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 { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component'; import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';
@NgModule({ @NgModule({
@ -15,7 +17,9 @@ import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.com
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfDialogHeaderModule,
MatButtonModule, MatButtonModule,
MatCheckboxModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,

View File

@ -0,0 +1,15 @@
:host {
display: block;
textarea.mat-input-element.cdk-textarea-autosize {
box-sizing: content-box;
}
.mat-checkbox {
::ng-deep {
label {
margin-bottom: 0;
}
}
}
}

View File

@ -28,7 +28,7 @@ export class PerformanceChartDialog {
public title: string; public title: string;
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<PerformanceChartDialog>, public dialogRef: MatDialogRef<PerformanceChartDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -46,7 +46,7 @@ export class PerformanceChartDialog {
this.historicalDataItems = this.data.historicalDataItems; this.historicalDataItems = this.data.historicalDataItems;
this.historicalDataItems.forEach((historicalDataItem) => { this.historicalDataItems?.forEach((historicalDataItem) => {
const benchmarkItem = historicalData.find((item) => { const benchmarkItem = historicalData.find((item) => {
return item.date === historicalDataItem.date; return item.date === historicalDataItem.date;
}); });
@ -75,7 +75,7 @@ export class PerformanceChartDialog {
} }
}); });
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.title = `Performance vs. ${this.benchmarkLabel}`; this.title = `Performance vs. ${this.benchmarkLabel}`;

View File

@ -1,158 +0,0 @@
// import 'chartjs-chart-timeline';
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { PortfolioItem } from '@ghostfolio/common/interfaces';
import { endOfDay, parseISO, startOfDay } from 'date-fns';
@Component({
selector: 'gf-portfolio-positions-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-positions-chart.component.html',
styleUrls: ['./portfolio-positions-chart.component.scss']
})
export class PortfolioPositionsChartComponent implements OnChanges, OnInit {
@Input() portfolioItems: PortfolioItem[];
// @ViewChild('timelineCanvas') timeline;
public isLoading = true;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.portfolioItems) {
this.initialize();
}
}
private initialize() {
this.isLoading = true;
let datasets = [];
const fromToPosition = {};
this.portfolioItems.forEach((positionsByDay) => {
Object.keys(positionsByDay.positions).forEach((symbol) => {
if (fromToPosition[symbol]) {
fromToPosition[symbol].push({
date: positionsByDay.date,
quantity: positionsByDay.positions[symbol].quantity
});
} else {
fromToPosition[symbol] = [
{
date: positionsByDay.date,
quantity: positionsByDay.positions[symbol].quantity
}
];
}
});
});
Object.keys(fromToPosition).forEach((symbol) => {
let currentDate = null;
let currentQuantity = null;
let data = [];
let hasStock = false;
fromToPosition[symbol].forEach((x, index) => {
if (x.quantity > 0 && index === 0) {
currentDate = x.date;
hasStock = true;
}
if (x.quantity === 0 || index === fromToPosition[symbol].length - 1) {
if (hasStock) {
data.push([
startOfDay(parseISO(currentDate)),
endOfDay(parseISO(x.date)),
currentQuantity
]);
hasStock = false;
} else {
// Do nothing
}
} else {
if (hasStock) {
// Do nothing
} else {
currentDate = x.date;
hasStock = true;
}
}
currentQuantity = x.quantity;
});
if (data.length === 0) {
// Fill data for today
data.push([
startOfDay(new Date()),
endOfDay(new Date()),
currentQuantity
]);
}
datasets.push({ data, symbol });
});
// Sort by date
datasets = datasets.sort((a: any, b: any) => {
return a.data[0][0].getTime() - b.data[0][0].getTime();
});
/*new Chart(this.timeline.nativeElement, {
type: 'timeline',
options: {
elements: {
colorFunction: (text, data, dataset, index) => {
return `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`;
},
showText: false,
textPadding: 4
},
maintainAspectRatio: true,
responsive: true,
scales: {
xAxes: [
{
gridLines: {
display: false
},
position: 'top',
time: {
unit: 'year'
}
}
],
yAxes: [
{
gridLines: {
display: false
},
ticks: {
display: false
}
}
]
}
},
data: {
datasets,
labels: datasets.map((dataset) => {
return dataset.symbol;
})
}
});*/
this.isLoading = false;
}
}

View File

@ -29,6 +29,7 @@ export class PortfolioProportionChartComponent
@Input() isInPercent: boolean; @Input() isInPercent: boolean;
@Input() key: string; @Input() key: string;
@Input() locale: string; @Input() locale: string;
@Input() maxItems?: number;
@Input() positions: { @Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number }; [symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
}; };
@ -90,12 +91,40 @@ export class PortfolioProportionChartComponent
} }
}); });
const chartDataSorted = Object.entries(chartData) let chartDataSorted = Object.entries(chartData)
.sort((a, b) => { .sort((a, b) => {
return a[1].value - b[1].value; return a[1].value - b[1].value;
}) })
.reverse(); .reverse();
if (this.maxItems && chartDataSorted.length > this.maxItems) {
// Add surplus items to unknown group
const rest = chartDataSorted.splice(
this.maxItems,
chartDataSorted.length - 1
);
let unknownItem = chartDataSorted.find((charDataItem) => {
return charDataItem[0] === UNKNOWN_KEY;
});
if (!unknownItem) {
const index = chartDataSorted.push([UNKNOWN_KEY, { value: 0 }]);
unknownItem = chartDataSorted[index];
}
rest.forEach((restItem) => {
unknownItem[1] = { value: unknownItem[1].value + restItem[1].value };
});
// Sort data again
chartDataSorted = chartDataSorted
.sort((a, b) => {
return a[1].value - b[1].value;
})
.reverse();
}
chartDataSorted.forEach(([symbol, item], index) => { chartDataSorted.forEach(([symbol, item], index) => {
if (this.colorMap[symbol]) { if (this.colorMap[symbol]) {
// Reuse color // Reuse color

View File

@ -34,7 +34,7 @@ export class PositionDetailDialog {
public transactionCount: number; public transactionCount: number;
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<PositionDetailDialog>, public dialogRef: MatDialogRef<PositionDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams @Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
@ -127,7 +127,7 @@ export class PositionDetailDialog {
this.benchmarkDataItems[0].value = this.averagePrice; this.benchmarkDataItems[0].value = this.averagePrice;
} }
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );
} }

View File

@ -8,7 +8,7 @@
[removable]="true" [removable]="true"
(removed)="removeKeyword(searchKeyword)" (removed)="removeKeyword(searchKeyword)"
> >
{{ searchKeyword }} {{ searchKeyword | gfSymbol }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon> <ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip> </mat-chip>
<input <input
@ -26,11 +26,8 @@
#autocomplete="matAutocomplete" #autocomplete="matAutocomplete"
(optionSelected)="keywordSelected($event)" (optionSelected)="keywordSelected($event)"
> >
<mat-option <mat-option *ngFor="let filter of filters | async" [value]="filter">
*ngFor="let transaction of filteredTransactions | async" {{ filter | gfSymbol }}
[value]="transaction"
>
{{ transaction }}
</mat-option> </mat-option>
</mat-autocomplete> </mat-autocomplete>
</mat-form-field> </mat-form-field>
@ -43,6 +40,16 @@
mat-table mat-table
[dataSource]="dataSource" [dataSource]="dataSource"
> >
<ng-container matColumnDef="count">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>#</th>
<td
*matCellDef="let element; let i = index"
class="px-1 text-right"
mat-cell
>
{{ dataSource.data.length - i }}
</td>
</ng-container>
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<th <th
*matHeaderCellDef *matHeaderCellDef
@ -178,9 +185,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="account"> <ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Account</th>
Account
</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex"> <div class="d-flex">
<gf-symbol-icon <gf-symbol-icon

View File

@ -57,10 +57,8 @@ export class TransactionsTableComponent
public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource(); public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public displayedColumns = []; public displayedColumns = [];
public filteredTransactions$: Subject<string[]> = new BehaviorSubject([]); public filters$: Subject<string[]> = new BehaviorSubject([]);
public filteredTransactions: Observable< public filters: Observable<string[]> = this.filters$.asObservable();
string[]
> = this.filteredTransactions$.asObservable();
public isLoading = true; public isLoading = true;
public placeholder = ''; public placeholder = '';
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
@ -68,7 +66,7 @@ export class TransactionsTableComponent
public searchKeywords: string[] = []; public searchKeywords: string[] = [];
public separatorKeysCodes: number[] = [ENTER, COMMA]; public separatorKeysCodes: number[] = [ENTER, COMMA];
private allFilteredTransactions: string[]; private allFilters: string[];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
@ -90,13 +88,13 @@ export class TransactionsTableComponent
this.searchControl.valueChanges.subscribe((keyword) => { this.searchControl.valueChanges.subscribe((keyword) => {
if (keyword) { if (keyword) {
const filterValue = keyword.toLowerCase(); const filterValue = keyword.toLowerCase();
this.filteredTransactions$.next( this.filters$.next(
this.allFilteredTransactions.filter( this.allFilters.filter(
(filter) => filter.toLowerCase().indexOf(filterValue) === 0 (filter) => filter.toLowerCase().indexOf(filterValue) === 0
) )
); );
} else { } else {
this.filteredTransactions$.next(this.allFilteredTransactions); this.filters$.next(this.allFilters);
} }
}); });
} }
@ -135,6 +133,7 @@ export class TransactionsTableComponent
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = [ this.displayedColumns = [
'count',
'date', 'date',
'type', 'type',
'symbol', 'symbol',
@ -239,13 +238,13 @@ export class TransactionsTableComponent
this.placeholder = this.placeholder =
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : ''; lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
this.allFilteredTransactions = this.getSearchableFieldValues( this.allFilters = this.getSearchableFieldValues(this.transactions).filter(
this.transactions (item) => {
).filter((item) => { return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase()); }
}); );
this.filteredTransactions$.next(this.allFilteredTransactions); this.filters$.next(this.allFilters);
} }
private getSearchableFieldValues(transactions: OrderWithAccount[]): string[] { private getSearchableFieldValues(transactions: OrderWithAccount[]): string[] {

View File

@ -1,12 +1,10 @@
<ngx-skeleton-loader <ngx-skeleton-loader
*ngIf="isLoading" *ngIf="isLoading"
animation="pulse" animation="pulse"
class="h-100"
[theme]="{ [theme]="{
height: '30rem',
width: '100%' width: '100%'
}" }"
></ngx-skeleton-loader> ></ngx-skeleton-loader>
<canvas
#timelineCanvas <div class="align-items-center d-flex h-100 w-100" id="svgMap"></div>
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>

View File

@ -0,0 +1,32 @@
:host {
display: block;
height: 100%;
::ng-deep {
.loader {
height: 100% !important;
}
.svgMap-map-wrapper {
background: transparent;
.svgMap-country {
stroke: #e5e5e5;
}
.svgMap-map-controls-wrapper {
display: none;
}
}
}
}
:host-context(.is-dark-theme) {
::ng-deep {
.svgMap-map-wrapper {
.svgMap-country {
stroke: #414141;
}
}
}
}

View File

@ -0,0 +1,77 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
OnDestroy,
OnInit
} from '@angular/core';
import { Currency } from '@prisma/client';
import svgMap from 'svgmap';
@Component({
selector: 'gf-world-map-chart',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './world-map-chart.component.html',
styleUrls: ['./world-map-chart.component.scss']
})
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: Currency;
@Input() countries: { [code: string]: { name: string; value: number } };
public isLoading = true;
public svgMapElement;
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
public ngOnInit() {}
public ngOnChanges() {
if (this.countries) {
this.isLoading = true;
this.destroySvgMap();
this.initialize();
}
}
public ngOnDestroy() {
this.destroySvgMap();
}
private initialize() {
this.svgMapElement = new svgMap({
colorMax: '#22bdb9',
colorMin: '#c3f1f0',
colorNoData: 'transparent',
data: {
applyData: 'value',
data: {
value: {
format: `{0} ${this.baseCurrency}`
}
},
values: this.countries
},
hideFlag: true,
minZoom: 1.06,
maxZoom: 1.06,
targetElementID: 'svgMap'
});
setTimeout(() => {
this.isLoading = false;
this.changeDetectorRef.markForCheck();
}, 500);
}
private destroySvgMap() {
this.svgMapElement?.mapWrapper?.remove();
this.svgMapElement?.tooltip?.remove();
this.svgMapElement = null;
}
}

View File

@ -2,12 +2,12 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PortfolioPositionsChartComponent } from './portfolio-positions-chart.component'; import { WorldMapChartComponent } from './world-map-chart.component';
@NgModule({ @NgModule({
declarations: [PortfolioPositionsChartComponent], declarations: [WorldMapChartComponent],
exports: [PortfolioPositionsChartComponent], exports: [WorldMapChartComponent],
imports: [CommonModule, NgxSkeletonLoaderModule], imports: [CommonModule, NgxSkeletonLoaderModule],
providers: [] providers: []
}) })
export class PortfolioPositionsChartModule {} export class GfWorldMapChartModule {}

View File

@ -9,15 +9,22 @@ import { ViewMode } from '@prisma/client';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { DataService } from '../services/data.service';
import { SettingsStorageService } from '../services/settings-storage.service'; import { SettingsStorageService } from '../services/settings-storage.service';
import { UserService } from '../services/user/user.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
private static PUBLIC_PAGE_ROUTES = [
'/about',
'/pricing',
'/register',
'/resources'
];
constructor( constructor(
private dataService: DataService,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService private settingsStorageService: SettingsStorageService,
private userService: UserService
) {} ) {}
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
@ -29,11 +36,17 @@ export class AuthGuard implements CanActivate {
} }
return new Promise<boolean>((resolve) => { return new Promise<boolean>((resolve) => {
this.dataService this.userService
.fetchUser() .get()
.pipe( .pipe(
catchError(() => { catchError(() => {
if (state.url !== '/start') { if (route.queryParams?.utm_source) {
this.router.navigate(['/register']);
resolve(false);
} else if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
resolve(true);
return EMPTY;
} else if (state.url !== '/start') {
this.router.navigate(['/start']); this.router.navigate(['/start']);
resolve(false); resolve(false);
return EMPTY; return EMPTY;
@ -45,12 +58,12 @@ export class AuthGuard implements CanActivate {
) )
.subscribe((user) => { .subscribe((user) => {
if ( if (
state.url === '/home' && state.url.startsWith('/home') &&
user.settings.viewMode === ViewMode.ZEN user.settings.viewMode === ViewMode.ZEN
) { ) {
this.router.navigate(['/zen']); this.router.navigate(['/zen']);
resolve(false); resolve(false);
} else if (state.url === '/start') { } else if (state.url.startsWith('/start')) {
if (user.settings.viewMode === ViewMode.ZEN) { if (user.settings.viewMode === ViewMode.ZEN) {
this.router.navigate(['/zen']); this.router.navigate(['/zen']);
} else { } else {
@ -59,7 +72,7 @@ export class AuthGuard implements CanActivate {
resolve(false); resolve(false);
} else if ( } else if (
state.url === '/zen' && state.url.startsWith('/zen') &&
user.settings.viewMode === ViewMode.DEFAULT user.settings.viewMode === ViewMode.DEFAULT
) { ) {
this.router.navigate(['/home']); this.router.navigate(['/home']);

View File

@ -2,12 +2,10 @@ import {
HTTP_INTERCEPTORS, HTTP_INTERCEPTORS,
HttpErrorResponse, HttpErrorResponse,
HttpEvent, HttpEvent,
HttpResponse
} from '@angular/common/http';
import {
HttpHandler, HttpHandler,
HttpInterceptor, HttpInterceptor,
HttpRequest HttpRequest,
HttpResponse
} from '@angular/common/http'; } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
@ -16,6 +14,7 @@ import {
TextOnlySnackBar TextOnlySnackBar
} from '@angular/material/snack-bar'; } from '@angular/material/snack-bar';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; import { catchError, tap } from 'rxjs/operators';
@ -29,7 +28,8 @@ export class HttpResponseInterceptor implements HttpInterceptor {
public constructor( public constructor(
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
private snackBar: MatSnackBar private snackBar: MatSnackBar,
private webAuthnService: WebAuthnService
) {} ) {}
public intercept( public intercept(
@ -78,7 +78,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
}); });
} }
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
this.tokenStorageService.signOut(); if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/webauthn']);
} else {
this.tokenStorageService.signOut();
}
} }
return throwError(''); return throwError('');

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
const routes: Routes = [{ path: '', component: AboutPageComponent }]; const routes: Routes = [
{ path: '', component: AboutPageComponent, canActivate: [AuthGuard] }
];
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],

View File

@ -1,8 +1,10 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config'; import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -15,8 +17,10 @@ import { environment } from '../../../environments/environment';
}) })
export class AboutPageComponent implements OnInit { export class AboutPageComponent implements OnInit {
public baseCurrency = baseCurrency; public baseCurrency = baseCurrency;
public hasPermissionForStatistics: boolean;
public isLoggedIn: boolean; public isLoggedIn: boolean;
public lastPublish = environment.lastPublish; public lastPublish = environment.lastPublish;
public statistics: Statistics;
public user: User; public user: User;
public version = environment.version; public version = environment.version;
@ -26,28 +30,37 @@ export class AboutPageComponent implements OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private tokenStorageService: TokenStorageService private userService: UserService
) {} ) {}
/** /**
* Initializes the controller * Initializes the controller
*/ */
public ngOnInit() { public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken(); this.dataService
.fetchInfo()
.subscribe(({ globalPermissions, statistics }) => {
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
if (this.isLoggedIn) this.statistics = statistics;
this.tokenStorageService
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -5,15 +5,14 @@
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-content> <mat-card-content>
<p> <p>
<strong>Ghostfolio</strong> ({{ version }}) is open source software <strong>Ghostfolio</strong> is open source software which empowers
which empowers busy folks to have a sharp look of their financial busy folks to have a sharp look of their financial assets and to
assets and to make solid, data-driven investment decisions by make solid, data-driven investment decisions by evaluating automated
evaluating automated static portfolio analysis rules. The project static portfolio analysis rules. The project has been initiated by
has been initiated by
<a href="https://dotsilver.ch">Thomas Kaul</a>. <a href="https://dotsilver.ch">Thomas Kaul</a>.
<ng-container *ngIf="lastPublish"> <ng-container *ngIf="lastPublish">
This instance has been last published on {{ lastPublish This instance is running Ghostfolio {{ version }} and has been
}}.</ng-container last published on {{ lastPublish }}.</ng-container
> >
</p> </p>
<p> <p>
@ -61,6 +60,40 @@
</div> </div>
</div> </div>
<div *ngIf="hasPermissionForStatistics" class="mb-5 row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Ghostfolio in Numbers</h3>
<mat-card class="mb-3">
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
{{ statistics?.activeUsers1d ?? '-' }}
</h3>
<div class="h6 mb-0">
Active Users <small class="text-muted">(Last 24 hours)</small>
</div>
</div>
<div class="col-xs-12 col-md-4">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
{{ statistics?.activeUsers30d ?? '-' }}
</h3>
<div class="h6 m-b0">
Active Users <small class="text-muted">(Last 30 days)</small>
</div>
</div>
<div class="col-xs-12 col-md-4">
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
{{ statistics?.gitHubStargazers ?? '-' }}
</h3>
<div class="h6 mb-0">Stars on GitHub</div>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="mb-3 text-center" i18n>Changelog</h3> <h3 class="mb-3 text-center" i18n>Changelog</h3>

View File

@ -1,12 +1,23 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import {
MatSlideToggle,
MatSlideToggleChange
} from '@angular/material/slide-toggle';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'gf-account-page', selector: 'gf-account-page',
@ -14,11 +25,13 @@ import { takeUntil } from 'rxjs/operators';
styleUrls: ['./account-page.scss'] styleUrls: ['./account-page.scss']
}) })
export class AccountPageComponent implements OnDestroy, OnInit { export class AccountPageComponent implements OnDestroy, OnInit {
@ViewChild('toggleSignInWithFingerprintEnabledElement')
signInWithFingerprintElement: MatSlideToggle;
public accesses: Access[]; public accesses: Access[];
public baseCurrency: Currency; public baseCurrency: Currency;
public currencies: Currency[] = []; public currencies: Currency[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public hasPermissionForSubscription: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public user: User; public user: User;
@ -28,36 +41,31 @@ export class AccountPageComponent implements OnDestroy, OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private tokenStorageService: TokenStorageService private userService: UserService,
public webAuthnService: WebAuthnService
) { ) {
this.dataService this.dataService
.fetchInfo() .fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currencies, globalPermissions }) => { .subscribe(({ currencies }) => {
this.currencies = currencies; this.currencies = currencies;
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.dataService.fetchUser().subscribe((user) => { if (state?.user) {
this.user = user; this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission( this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions, this.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
); );
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); }
}); });
} }
@ -78,19 +86,70 @@ export class AccountPageComponent implements OnDestroy, OnInit {
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe(() => {
this.dataService.fetchUser().subscribe((user) => { this.userService.remove();
this.user = user;
this.cd.markForCheck(); this.userService
}); .get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
}); });
} }
public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) {
if (aEvent.checked) {
this.registerDevice();
} else {
const confirmation = confirm(
'Do you really want to remove this sign in method?'
);
if (confirmation) {
this.deregisterDevice();
} else {
this.update();
}
}
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private deregisterDevice() {
this.webAuthnService
.deregister()
.pipe(
catchError(() => {
this.update();
return EMPTY;
})
)
.subscribe(() => {
this.update();
});
}
private registerDevice() {
this.webAuthnService
.register()
.pipe(
catchError(() => {
this.update();
return EMPTY;
})
)
.subscribe(() => {
this.update();
});
}
private update() { private update() {
this.dataService this.dataService
.fetchAccesses() .fetchAccesses()
@ -98,7 +157,12 @@ export class AccountPageComponent implements OnDestroy, OnInit {
.subscribe((response) => { .subscribe((response) => {
this.accesses = response; this.accesses = response;
this.cd.markForCheck(); if (this.signInWithFingerprintElement) {
this.signInWithFingerprintElement.checked =
this.webAuthnService.isEnabled() ?? false;
}
this.changeDetectorRef.markForCheck();
}); });
} }
} }

View File

@ -15,16 +15,21 @@
<div class="w-50" i18n>Alias</div> <div class="w-50" i18n>Alias</div>
<div class="w-50">{{ user.alias }}</div> <div class="w-50">{{ user.alias }}</div>
</div> </div>
<div *ngIf="hasPermissionForSubscription" class="d-flex py-1"> <div *ngIf="user?.subscription" class="d-flex py-1">
<div class="w-50" i18n>Membership</div> <div class="w-50" i18n>Membership</div>
<div class="w-50"> <div class="w-50">
<div class="align-items-center d-flex mb-1"> <div class="align-items-center d-flex mb-1">
{{ user?.subscription?.type }} {{ user.subscription.type }}
</div> </div>
<div> <div *ngIf="user.subscription.expiresAt">
Valid until {{ user.subscription.expiresAt | date: Valid until {{ user.subscription.expiresAt | date:
defaultDateFormat }} defaultDateFormat }}
</div> </div>
<div *ngIf="!user.subscription.expiresAt">
<button color="primary" disabled i18n mat-flat-button>
Upgrade
</button>
</div>
</div> </div>
</div> </div>
<div class="d-flex mt-4 py-1"> <div class="d-flex mt-4 py-1">
@ -61,6 +66,17 @@
</form> </form>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="w-50" i18n>Sign in with fingerprint</div>
<div class="w-50">
<mat-slide-toggle
#toggleSignInWithFingerprintEnabledElement
color="primary"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)"
></mat-slide-toggle>
</div>
</div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -1,9 +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 { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
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 { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
import { AccountPageRoutingModule } from './account-page-routing.module'; import { AccountPageRoutingModule } from './account-page-routing.module';
@ -17,9 +21,13 @@ import { AccountPageComponent } from './account-page.component';
CommonModule, CommonModule,
FormsModule, FormsModule,
GfPortfolioAccessTableModule, GfPortfolioAccessTableModule,
MatButtonModule,
MatCardModule, MatCardModule,
MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule,
MatSelectModule, MatSelectModule,
MatSlideToggleModule,
ReactiveFormsModule ReactiveFormsModule
], ],
providers: [] providers: []

View File

@ -5,7 +5,7 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
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';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Account as AccountModel, AccountType } from '@prisma/client'; import { Account as AccountModel, AccountType } from '@prisma/client';
@ -35,14 +35,14 @@ export class AccountsPageComponent implements OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService private userService: UserService
) { ) {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -75,23 +75,23 @@ export class AccountsPageComponent implements OnInit {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.dataService.fetchUser().subscribe((user) => { if (state?.user) {
this.user = user; this.user = state.user;
this.hasPermissionToCreateAccount = hasPermission( this.hasPermissionToCreateAccount = hasPermission(
user.permissions, this.user.permissions,
permissions.createAccount permissions.createAccount
); );
this.hasPermissionToDeleteAccount = hasPermission( this.hasPermissionToDeleteAccount = hasPermission(
user.permissions, this.user.permissions,
permissions.deleteAccount permissions.deleteAccount
); );
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); }
}); });
this.fetchAccounts(); this.fetchAccounts();
@ -105,7 +105,7 @@ export class AccountsPageComponent implements OnInit {
this.router.navigate([], { queryParams: { createDialog: true } }); this.router.navigate([], { queryParams: { createDialog: true } });
} }
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }

View File

@ -2,10 +2,15 @@ import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { AdminData, User } from '@ghostfolio/common/interfaces'; import { AdminData, User } from '@ghostfolio/common/interfaces';
import { formatDistanceToNow, isValid, parseISO, sub } from 'date-fns'; import {
differenceInSeconds,
formatDistanceToNowStrict,
isValid,
parseISO
} from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -32,9 +37,9 @@ export class AdminPageComponent implements OnInit {
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private cacheService: CacheService, private cacheService: CacheService,
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private tokenStorageService: TokenStorageService private userService: UserService
) {} ) {}
/** /**
@ -43,13 +48,12 @@ export class AdminPageComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.fetchAdminData(); this.fetchAdminData();
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.dataService.fetchUser().subscribe((user) => { if (state?.user) {
this.user = user; this.user = state.user;
}); }
}); });
} }
@ -77,14 +81,12 @@ export class AdminPageComponent implements OnInit {
public formatDistanceToNow(aDateString: string) { public formatDistanceToNow(aDateString: string) {
if (aDateString) { if (aDateString) {
const distanceString = formatDistanceToNow( const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
sub(parseISO(aDateString), { seconds: 10 }), addSuffix: true
{ });
addSuffix: true
}
);
return distanceString === 'less than a minute ago' return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
60
? 'just now' ? 'just now'
: distanceString; : distanceString;
} }
@ -125,7 +127,7 @@ export class AdminPageComponent implements OnInit {
this.users = users; this.users = users;
if (isValid(parseISO(lastDataGathering?.toString()))) { if (isValid(parseISO(lastDataGathering?.toString()))) {
this.lastDataGathering = formatDistanceToNow( this.lastDataGathering = formatDistanceToNowStrict(
new Date(lastDataGathering), new Date(lastDataGathering),
{ {
addSuffix: true addSuffix: true
@ -140,7 +142,7 @@ export class AdminPageComponent implements OnInit {
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
this.userCount = userCount; this.userCount = userCount;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
} }
); );
} }

View File

@ -73,26 +73,40 @@
<table class="gf-table"> <table class="gf-table">
<thead> <thead>
<tr class="mat-header-row"> <tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>User</th> <th class="mat-header-cell px-1 py-2" i18n>User</th>
<th class="mat-header-cell px-1 py-2" i18n>Registration Date</th> <th class="mat-header-cell px-1 py-2 text-right" i18n>
<th class="mat-header-cell px-1 py-2" i18n>Accounts</th> Registration Date
<th class="mat-header-cell px-1 py-2" i18n>Transactions</th> </th>
<th class="mat-header-cell px-1 py-2" i18n>Engagement</th> <th class="mat-header-cell px-1 py-2 text-right" i18n>
Accounts
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Transactions
</th>
<th class="mat-header-cell px-1 py-2 text-right" i18n>
Engagement
</th>
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th> <th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
<th class="mat-header-cell px-1 py-2"></th> <th class="mat-header-cell px-1 py-2"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let userItem of users" class="mat-row"> <tr *ngFor="let userItem of users; let i = index" class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
{{ userItem.alias || userItem.id }} {{ userItem.alias || userItem.id }}
</td> </td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2 text-right">
{{ userItem.createdAt | date: defaultDateFormat }} {{ userItem.createdAt | date: defaultDateFormat }}
</td> </td>
<td class="mat-cell px-1 py-2">{{ userItem._count?.Account }}</td> <td class="mat-cell px-1 py-2 text-right">
<td class="mat-cell px-1 py-2">{{ userItem._count?.Order }}</td> {{ userItem._count?.Account }}
<td class="mat-cell px-1 py-2"> </td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem._count?.Order }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.Analytics?.activityCount }} {{ userItem.Analytics?.activityCount }}
</td> </td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">

View File

@ -1,5 +1,9 @@
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
@Component({ @Component({
@ -14,6 +18,7 @@ export class AuthPageComponent implements OnInit {
public constructor( public constructor(
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService private tokenStorageService: TokenStorageService
) {} ) {}
@ -23,7 +28,10 @@ export class AuthPageComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.route.params.subscribe((params) => { this.route.params.subscribe((params) => {
const jwt = params['jwt']; const jwt = params['jwt'];
this.tokenStorageService.saveToken(jwt); this.tokenStorageService.saveToken(
jwt,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
);
this.router.navigate(['/']); this.router.navigate(['/']);
}); });

View File

@ -10,7 +10,7 @@ import {
RANGE, RANGE,
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
PortfolioOverview, PortfolioOverview,
PortfolioPerformance, PortfolioPerformance,
@ -58,7 +58,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
@ -66,7 +66,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService private userService: UserService
) { ) {
this.routeQueryParams = this.route.queryParams this.routeQueryParams = this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -76,14 +76,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
} }
}); });
this.tokenStorageService this.userService.stateChanged
.onChangeHasToken()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => { .subscribe((state) => {
this.dataService.fetchUser().subscribe((user) => { if (state?.user) {
this.user = user; this.user = state.user;
this.hasPermissionToAccessFearAndGreedIndex = hasPermission( this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
user.permissions, this.user.permissions,
permissions.accessFearAndGreedIndex permissions.accessFearAndGreedIndex
); );
@ -94,17 +94,17 @@ export class HomePageComponent implements OnDestroy, OnInit {
.subscribe(({ marketPrice }) => { .subscribe(({ marketPrice }) => {
this.fearAndGreedIndex = marketPrice; this.fearAndGreedIndex = marketPrice;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
this.hasPermissionToReadForeignPortfolio = hasPermission( this.hasPermissionToReadForeignPortfolio = hasPermission(
user.permissions, this.user.permissions,
permissions.readForeignPortfolio permissions.readForeignPortfolio
); );
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); }
}); });
} }
@ -169,7 +169,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
}; };
}); });
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService this.dataService
@ -178,14 +178,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.performance = response; this.performance = response;
this.isLoadingPerformance = false; this.isLoadingPerformance = false;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService.fetchPortfolioOverview().subscribe((response) => { this.dataService.fetchPortfolioOverview().subscribe((response) => {
this.overview = response; this.overview = response;
this.isLoadingOverview = false; this.isLoadingOverview = false;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.dataService this.dataService
@ -195,9 +195,9 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.hasPositions = this.hasPositions =
this.positions && Object.keys(this.positions).length > 0; this.positions && Object.keys(this.positions).length > 0;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
} }
} }

View File

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

View File

@ -1,21 +1,18 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
@Component({ @Component({
selector: 'gf-login-page', selector: 'gf-landing-page',
templateUrl: './login-page.html', templateUrl: './landing-page.html',
styleUrls: ['./login-page.scss'] styleUrls: ['./landing-page.scss']
}) })
export class LoginPageComponent implements OnDestroy, OnInit { export class LandingPageComponent implements OnDestroy, OnInit {
public currentYear = format(new Date(), 'yyyy'); public currentYear = format(new Date(), 'yyyy');
public demoAuthToken: string; public demoAuthToken: string;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
@ -26,9 +23,8 @@ export class LoginPageComponent implements OnDestroy, OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private dialog: MatDialog,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService private tokenStorageService: TokenStorageService
) {} ) {}
@ -42,19 +38,10 @@ export class LoginPageComponent implements OnDestroy, OnInit {
this.initializeLineChart(); this.initializeLineChart();
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
public async createAccount() {
this.dataService
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken }) => {
this.openShowAccessTokenDialog(accessToken, authToken);
});
}
public initializeLineChart() { public initializeLineChart() {
this.historicalDataItems = [ this.historicalDataItems = [
{ {
@ -268,30 +255,8 @@ export class LoginPageComponent implements OnDestroy, OnInit {
]; ];
} }
public openShowAccessTokenDialog(
accessToken: string,
authToken: string
): void {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
accessToken,
authToken
},
disableClose: true,
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken);
this.router.navigate(['/']);
}
});
}
public setToken(aToken: string) { public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken); this.tokenStorageService.saveToken(aToken, true);
this.router.navigate(['/']); this.router.navigate(['/']);
} }

View File

@ -13,16 +13,16 @@
class="align-items-center col d-flex justify-content-center position-relative" class="align-items-center col d-flex justify-content-center position-relative"
> >
<div class="py-5 text-center"> <div class="py-5 text-center">
<button <a
class="d-inline-block" class="d-inline-block"
color="primary" color="primary"
i18n i18n
mat-flat-button mat-flat-button
[disabled]="!demoAuthToken" [disabled]="!demoAuthToken"
(click)="createAccount()" [routerLink]="['/register']"
> >
Create Account Get Started
</button> </a>
<div class="d-inline-block mx-3 text-muted" i18n>or</div> <div class="d-inline-block mx-3 text-muted" i18n>or</div>
<button <button
class="d-inline-block" class="d-inline-block"
@ -135,15 +135,15 @@
Join now or check out the example account Join now or check out the example account
</p> </p>
<div class="py-2 text-center"> <div class="py-2 text-center">
<button <a
color="primary" color="primary"
i18n i18n
mat-flat-button mat-flat-button
[disabled]="!demoAuthToken" [disabled]="!demoAuthToken"
(click)="createAccount()" [routerLink]="['/register']"
> >
Create Account Get Started
</button> </a>
<div class="d-inline-block mx-3 text-muted" i18n>or</div> <div class="d-inline-block mx-3 text-muted" i18n>or</div>
<button <button
class="d-inline-block" class="d-inline-block"

View File

@ -0,0 +1,25 @@
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 { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { LandingPageRoutingModule } from './landing-page-routing.module';
import { LandingPageComponent } from './landing-page.component';
@NgModule({
declarations: [LandingPageComponent],
exports: [],
imports: [
CommonModule,
GfLineChartModule,
GfLogoModule,
LandingPageRoutingModule,
MatButtonModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class LandingPageModule {}

View File

@ -1,14 +0,0 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
@Component({
selector: 'login-with-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./login-with-access-token-dialog.scss'],
templateUrl: 'login-with-access-token-dialog.html'
})
export class LoginWithAccessTokenDialog {
public constructor(@Inject(MAT_DIALOG_DATA) public data: any) {}
ngOnInit() {}
}

View File

@ -1,5 +0,0 @@
:host {
textarea.mat-input-element.cdk-textarea-autosize {
box-sizing: content-box;
}
}

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { PricingPageComponent } from './pricing-page.component'; import { PricingPageComponent } from './pricing-page.component';
const routes: Routes = [{ path: '', component: PricingPageComponent }]; const routes: Routes = [
{ path: '', component: PricingPageComponent, canActivate: [AuthGuard] }
];
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],

View File

@ -1,6 +1,5 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { baseCurrency } from '@ghostfolio/common/config'; import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -22,28 +21,23 @@ export class PricingPageComponent implements OnInit {
* @constructor * @constructor
*/ */
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private userService: UserService
private tokenStorageService: TokenStorageService
) {} ) {}
/** /**
* Initializes the controller * Initializes the controller
*/ */
public ngOnInit() { public ngOnInit() {
this.isLoggedIn = !!this.tokenStorageService.getToken(); this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
if (this.isLoggedIn) this.changeDetectorRef.markForCheck();
this.tokenStorageService }
.onChangeHasToken() });
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.dataService.fetchUser().subscribe((user) => {
this.user = user;
this.cd.markForCheck();
});
});
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -1,96 +1,185 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Pricing Plans</h3> <h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Pricing Plans
</h3>
<mat-card class="mb-4">
<mat-card-content>
<p>
Our official
<strong>Ghostfolio</strong> cloud offering is the easiest way to get
started. Due to the time it saves, this will be the best option for
most people. The revenue is used for covering the hosting costs.
</p>
<p>
If you prefer to run <strong>Ghostfolio</strong> on your own
infrastructure, please find the source code and further instructions
on <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p>
</mat-card-content>
</mat-card>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-6"> <div class="col-xs-12 col-md-4 mb-3">
<mat-card class="mb-3"> <mat-card class="d-flex flex-column h-100">
<h4 i18n>Open Source</h4> <div class="flex-grow-1">
<p>Host your <strong>Ghostfolio</strong> instance by yourself.</p> <h4 i18n>Open Source</h4>
<ul class="list-unstyled mb-3"> <p>
<li class="align-items-center d-flex mb-1"> For tech-savvy investors who prefer to run
<ion-icon <strong>Ghostfolio</strong> on their own infrastructure.
class="mr-1 text-muted" </p>
name="checkmark-circle-outline" <ul class="list-unstyled mb-3">
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Portfolio Performance</span> <ion-icon
</li> class="mr-1 text-muted"
<li class="align-items-center d-flex mb-1"> name="checkmark-circle-outline"
<ion-icon ></ion-icon>
class="mr-1 text-muted" <span>Unlimited Transactions</span>
name="checkmark-circle-outline" </li>
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Portfolio Summary</span> <ion-icon
</li> class="mr-1 text-muted"
<li class="align-items-center d-flex mb-1"> name="checkmark-circle-outline"
<ion-icon ></ion-icon>
class="mr-1 text-muted" <span>Portfolio Performance</span>
name="checkmark-circle-outline" </li>
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Unlimited Transactions</span> <ion-icon
</li> class="mr-1 text-muted"
<li class="align-items-center d-flex mb-1"> name="checkmark-circle-outline"
<ion-icon ></ion-icon>
class="mr-1 text-muted" <span>Zen Mode</span>
name="checkmark-circle-outline" </li>
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Advanced Insights</span> <ion-icon
</li> class="mr-1 text-muted"
</ul> name="checkmark-circle-outline"
<p class="h5 text-right"> ></ion-icon>
<span>Free</span> <span>Portfolio Summary</span>
</p> </li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
</div>
<p>Self-hosted.</p>
<p class="h5 text-right">Free</p>
</mat-card> </mat-card>
</div> </div>
<div class="col-xs-12 col-md-6"> <div class="col-xs-12 col-md-4 mb-3">
<mat-card <mat-card
class="mb-3" class="d-flex flex-column h-100"
[ngClass]="{ 'active': user?.subscription?.type === 'Trial' }" [ngClass]="{ 'active': user?.subscription?.type === 'Basic' }"
> >
<h4 class="align-items-center d-flex" i18n> <div class="flex-grow-1">
Diamond <h4 class="align-items-center d-flex" i18n>Basic</h4>
<ion-icon <p>
class="ml-1 text-muted" For new investors who are just getting started with trading.
name="diamond-outline" </p>
></ion-icon> <ul class="list-unstyled mb-3">
</h4> <li class="align-items-center d-flex mb-1">
<p> <ion-icon
Get a fully managed <strong>Ghostfolio</strong> cloud offering. class="mr-1 text-muted"
</p> name="checkmark-circle-outline"
<ul class="list-unstyled mb-3"> ></ion-icon>
<li class="align-items-center d-flex mb-1"> <span>Unlimited Transactions</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Performance</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Zen Mode</span>
</li>
<li>
<ion-icon
class="invisible"
name="checkmark-circle-outline"
></ion-icon>
</li>
<li>
<ion-icon
class="invisible"
name="checkmark-circle-outline"
></ion-icon>
</li>
</ul>
</div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right">Free</p>
</mat-card>
</div>
<div class="col-xs-12 col-md-4 mb-3">
<mat-card
class="d-flex flex-column h-100"
[ngClass]="{ 'active': user?.subscription?.type === 'Premium' }"
>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>
Premium
<ion-icon <ion-icon
class="mr-1 text-muted" class="ml-1 text-muted"
name="checkmark-circle-outline" name="diamond-outline"
></ion-icon> ></ion-icon>
<span>Portfolio Performance</span> </h4>
</li> <p>
<li class="align-items-center d-flex mb-1"> For ambitious investors who need the full picture of their
<ion-icon financial assets.
class="mr-1 text-muted" </p>
name="checkmark-circle-outline" <ul class="list-unstyled mb-3">
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Portfolio Summary</span> <ion-icon
</li> class="mr-1 text-muted"
<li class="align-items-center d-flex mb-1"> name="checkmark-circle-outline"
<ion-icon ></ion-icon>
class="mr-1 text-muted" <span>Unlimited Transactions</span>
name="checkmark-circle-outline" </li>
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Unlimited Transactions</span> <ion-icon
</li> class="mr-1 text-muted"
<li class="align-items-center d-flex mb-1"> name="checkmark-circle-outline"
<ion-icon ></ion-icon>
class="mr-1 text-muted" <span>Portfolio Performance</span>
name="checkmark-circle-outline" </li>
></ion-icon> <li class="align-items-center d-flex mb-1">
<span>Advanced Insights</span> <ion-icon
</li> class="mr-1 text-muted"
</ul> name="checkmark-circle-outline"
></ion-icon>
<span>Zen Mode</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Portfolio Summary</span>
</li>
<li class="align-items-center d-flex mb-1">
<ion-icon
class="mr-1 text-muted"
name="checkmark-circle-outline"
></ion-icon>
<span>Advanced Insights</span>
</li>
</ul>
</div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right"> <p class="h5 text-right">
<span class="font-weight-normal" <span class="font-weight-normal"
>{{ user?.settings.baseCurrency || baseCurrency }} >{{ user?.settings.baseCurrency || baseCurrency }}
<strong>2.99</strong> <strong>0.00</strong>
<del class="ml-1 text-muted">3.99</del> / Month</span <del class="ml-1 text-muted">3.99</del> / Month</span
> >
</p> </p>
@ -99,4 +188,12 @@
</div> </div>
</div> </div>
</div> </div>
<div *ngIf="!user" class="row">
<div class="col mt-3 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/register']">
Get Started
</a>
<p class="text-muted"><small>It's free</small></p>
</div>
</div>
</div> </div>

View File

@ -1,6 +1,8 @@
import { CommonModule } from '@angular/common'; 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 { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { PricingPageRoutingModule } from './pricing-page-routing.module'; import { PricingPageRoutingModule } from './pricing-page-routing.module';
import { PricingPageComponent } from './pricing-page.component'; import { PricingPageComponent } from './pricing-page.component';
@ -8,7 +10,13 @@ import { PricingPageComponent } from './pricing-page.component';
@NgModule({ @NgModule({
declarations: [PricingPageComponent], declarations: [PricingPageComponent],
exports: [], exports: [],
imports: [CommonModule, MatCardModule, PricingPageRoutingModule], imports: [
CommonModule,
MatButtonModule,
MatCardModule,
PricingPageRoutingModule,
RouterModule
],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

View File

@ -2,6 +2,15 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: bold;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
.mat-card { .mat-card {
&.active { &.active {
border-color: rgba(var(--palette-primary-500), 1); border-color: rgba(var(--palette-primary-500), 1);
@ -11,4 +20,8 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
a {
color: rgb(var(--light-primary-text));
}
} }

View File

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

View File

@ -0,0 +1,92 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { format } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
@Component({
selector: 'gf-register-page',
templateUrl: './register-page.html',
styleUrls: ['./register-page.scss']
})
export class RegisterPageComponent implements OnDestroy, OnInit {
public currentYear = format(new Date(), 'yyyy');
public demoAuthToken: string;
public hasPermissionForSocialLogin: boolean;
public historicalDataItems: LineChartItem[];
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private dialog: MatDialog,
private router: Router,
private tokenStorageService: TokenStorageService
) {
this.tokenStorageService.signOut();
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.dataService
.fetchInfo()
.subscribe(({ demoAuthToken, globalPermissions }) => {
this.demoAuthToken = demoAuthToken;
this.hasPermissionForSocialLogin = hasPermission(
globalPermissions,
permissions.enableSocialLogin
);
this.changeDetectorRef.markForCheck();
});
}
public async createAccount() {
this.dataService
.postUser()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accessToken, authToken }) => {
this.openShowAccessTokenDialog(accessToken, authToken);
});
}
public openShowAccessTokenDialog(
accessToken: string,
authToken: string
): void {
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
data: {
accessToken,
authToken
},
disableClose: true,
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.authToken) {
this.tokenStorageService.saveToken(authToken, true);
this.router.navigate(['/']);
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,30 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Create your Account
</h3>
<mat-card class="mb-4">
<mat-card-content class="text-center">
<button
class="d-inline-block"
color="primary"
i18n
mat-flat-button
[disabled]="!demoAuthToken"
(click)="createAccount()"
>
Create Account
</button>
<ng-container *ngIf="hasPermissionForSocialLogin">
<div class="my-3 text-muted" i18n>or</div>
<a color="accent" href="/api/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Continue with Google</span></a
>
</ng-container>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -1,27 +1,27 @@
import { CommonModule } from '@angular/common'; 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 { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module'; import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { LoginPageRoutingModule } from './login-page-routing.module'; import { RegisterPageRoutingModule } from './register-page-routing.module';
import { LoginPageComponent } from './login-page.component'; import { RegisterPageComponent } from './register-page.component';
import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module'; import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module';
@NgModule({ @NgModule({
declarations: [LoginPageComponent], declarations: [RegisterPageComponent],
exports: [], exports: [],
imports: [ imports: [
CommonModule, CommonModule,
GfLineChartModule,
GfLogoModule, GfLogoModule,
LoginPageRoutingModule,
MatButtonModule, MatButtonModule,
MatCardModule,
RegisterPageRoutingModule,
RouterModule, RouterModule,
ShowAccessTokenDialogModule ShowAccessTokenDialogModule
], ],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class LoginPageModule {} export class RegisterPageModule {}

View File

@ -7,7 +7,7 @@ import {
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MAT_DIALOG_DATA } from '@angular/material/dialog';
@Component({ @Component({
selector: 'show-access-token-dialog', selector: 'gf-show-access-token-dialog',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./show-access-token-dialog.scss'], styleUrls: ['./show-access-token-dialog.scss'],
templateUrl: 'show-access-token-dialog.html' templateUrl: 'show-access-token-dialog.html'
@ -16,7 +16,7 @@ export class ShowAccessTokenDialog {
public isAgreeButtonDisabled = true; public isAgreeButtonDisabled = true;
public constructor( public constructor(
private cd: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: any @Inject(MAT_DIALOG_DATA) public data: any
) {} ) {}
@ -26,7 +26,7 @@ export class ShowAccessTokenDialog {
setTimeout(() => { setTimeout(() => {
this.isAgreeButtonDisabled = false; this.isAgreeButtonDisabled = false;
this.cd.markForCheck(); this.changeDetectorRef.markForCheck();
}, 1500); }, 1500);
} }
} }

View File

@ -1,5 +1,5 @@
import { ClipboardModule } from '@angular/cdk/clipboard'; import { ClipboardModule } from '@angular/cdk/clipboard';
import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';

View File

@ -1,9 +1,12 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ResourcesPageComponent } from './resources-page.component'; import { ResourcesPageComponent } from './resources-page.component';
const routes: Routes = [{ path: '', component: ResourcesPageComponent }]; const routes: Routes = [
{ path: '', component: ResourcesPageComponent, canActivate: [AuthGuard] }
];
@NgModule({ @NgModule({
imports: [RouterModule.forChild(routes)], imports: [RouterModule.forChild(routes)],

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