Compare commits
62 Commits
Author | SHA1 | Date | |
---|---|---|---|
373a2015c0 | |||
66c955ad6c | |||
a2440fc067 | |||
3d7624d997 | |||
0264b592b9 | |||
198eaf57d3 | |||
6783ea2ebb | |||
a35701fe24 | |||
5db90f1787 | |||
81fe538484 | |||
51884913be | |||
8886082dfa | |||
3b12e5b85b | |||
6c1119caec | |||
698d5ec3b7 | |||
e87c942cb8 | |||
f7860a9799 | |||
c519eb0e99 | |||
8314b98f81 | |||
194cf1ddcc | |||
7da6478699 | |||
4f2bbba782 | |||
9eb25f6c9e | |||
f74b00446c | |||
beb7e6ec34 | |||
2eafc042ad | |||
74954bc51d | |||
6a03120225 | |||
21504573b4 | |||
fabd912fba | |||
00b42855b6 | |||
ef272360fb | |||
026a5011d4 | |||
aa4206af0e | |||
7788465272 | |||
3066dfd805 | |||
34303163bc | |||
e7fbcd4fa0 | |||
7c22969de1 | |||
6623bc0113 | |||
146b5201b5 | |||
b021fbde59 | |||
ec046b81a7 | |||
aea497154a | |||
dc736d53b4 | |||
5957b33779 | |||
bafdce56ad | |||
42a2d404e4 | |||
11b2379d98 | |||
c0657a2e9e | |||
646dcb91c5 | |||
ad961f3039 | |||
c16f743b07 | |||
8e13f6ef9b | |||
95bcdea69b | |||
0d6fe4a232 | |||
ced4519412 | |||
ef8b7718b1 | |||
b4762dc463 | |||
9851cce382 | |||
a1460a98fd | |||
1a553a296f |
2
.env
2
.env
@ -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=
|
||||||
|
@ -8,3 +8,4 @@ before_script:
|
|||||||
script:
|
script:
|
||||||
- yarn format:check
|
- yarn format:check
|
||||||
- yarn test
|
- yarn test
|
||||||
|
- yarn build:all
|
||||||
|
162
CHANGELOG.md
162
CHANGELOG.md
@ -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
|
||||||
|
|
||||||
|
24
README.md
24
README.md
@ -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
|
||||||
|
|
||||||
|
12
angular.json
12
angular.json
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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(),
|
||||||
|
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface AuthDeviceDto {
|
||||||
|
createdAt: string;
|
||||||
|
id: string;
|
||||||
|
}
|
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal 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 {}
|
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {}
|
||||||
|
@ -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;
|
||||||
|
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal 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[];
|
||||||
|
}
|
216
apps/api/src/app/auth/web-auth.service.ts
Normal file
216
apps/api/src/app/auth/web-auth.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@ -37,7 +37,9 @@ export class ExperimentalController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return benchmarks;
|
return benchmarks.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('benchmarks/:symbol')
|
@Get('benchmarks/:symbol')
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
);
|
);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
})
|
})
|
||||||
|
@ -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' })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 [
|
||||||
|
@ -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:
|
||||||
|
@ -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 }> {
|
||||||
|
@ -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 }> {
|
||||||
|
@ -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 }> {
|
||||||
|
@ -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', '');
|
||||||
};
|
};
|
||||||
|
@ -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(
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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];
|
||||||
|
@ -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'
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
@ -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: () =>
|
||||||
|
@ -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>
|
||||||
|
@ -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%;
|
||||||
|
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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(['/']);
|
||||||
}
|
}
|
||||||
|
@ -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';
|
||||||
|
@ -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
|
||||||
},
|
},
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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,
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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}`;
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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[] {
|
||||||
|
@ -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>
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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 {}
|
@ -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']);
|
||||||
|
@ -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('');
|
||||||
|
@ -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)],
|
||||||
|
@ -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() {
|
||||||
|
@ -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>
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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: []
|
||||||
|
@ -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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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">
|
||||||
|
@ -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(['/']);
|
||||||
});
|
});
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {}
|
@ -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(['/']);
|
||||||
}
|
}
|
@ -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"
|
25
apps/client/src/app/pages/landing/landing-page.module.ts
Normal file
25
apps/client/src/app/pages/landing/landing-page.module.ts
Normal 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 {}
|
@ -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() {}
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
:host {
|
|
||||||
textarea.mat-input-element.cdk-textarea-autosize {
|
|
||||||
box-sizing: content-box;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)],
|
||||||
|
@ -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() {
|
||||||
|
@ -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>
|
||||||
|
@ -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]
|
||||||
})
|
})
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {}
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
30
apps/client/src/app/pages/register/register-page.html
Normal file
30
apps/client/src/app/pages/register/register-page.html
Normal 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>
|
@ -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 {}
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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';
|
@ -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
Reference in New Issue
Block a user