Compare commits

..

40 Commits

Author SHA1 Message Date
290e93bbd7 Release 1.23.1 (#191) 2021-07-03 12:20:36 +02:00
b08ecd1b18 Release 1.23.0 (#190) 2021-07-03 11:51:26 +02:00
92d321a001 Drafts for orders (#187)
* Render the future with a dashed border

* Update changelog
2021-07-03 11:32:03 +02:00
ce2d8d519d Change from travis-ci.org to travis-ci.com (#188) 2021-06-30 21:52:29 +02:00
f32bef071e Add contributing section (#186) 2021-06-27 10:23:51 +02:00
4aa7365d9b Release 1.22.0 (#185) 2021-06-25 17:34:49 +02:00
367f25a975 Feature/set user id in stripe callback (#184)
* Set user id as description

* Update changelog
2021-06-24 21:52:41 +02:00
9832334da1 Move @types/lodash to dev dependencies (#183) 2021-06-23 17:36:40 +02:00
e126f9ec54 Release 1.21.0 (#182) 2021-06-22 21:55:00 +02:00
09bbda3502 Change from subscription to one time payment (#181) 2021-06-22 21:53:29 +02:00
ee9a521813 Bugfix/fix base currency in pricing page (#180)
* Fix base currency

* Update changelog
2021-06-21 20:52:01 +02:00
169c151547 Feature/improve style of about page (#177)
* Improve style

* Update changelog
2021-06-21 20:08:45 +02:00
3a95ec0f81 Release 1.20.0 (#179) 2021-06-21 20:05:54 +02:00
ad00cd9d81 Feature/setup subscription with stripe (#178)
* Set up stripe for subscriptions

* Update permissions and add discount

* Update changelog
2021-06-21 20:03:36 +02:00
373a2015c0 Release 1.19.0 (#176) 2021-06-17 23:01:59 +02:00
66c955ad6c Feature/ghostfolio in numbers (#175)
* Add Ghostfolio in numbers section

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

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

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

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

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

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

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

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

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

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

* Move webauthn login to separate page /webauthn

* Stay signed in with social login

* Update changelog

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

* Complete WebAuthn device sign up and login

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

* Fix after rebase

* Fix tests

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

* Add option to "Stay signed in"

* Remove device list feature, sign in with deviceId instead

* Improve usability

* Update changelog

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

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

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

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

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

* Extend seed

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

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

* Update changelog
2021-06-08 21:59:46 +02:00
107 changed files with 4974 additions and 2822 deletions

View File

@ -5,12 +5,117 @@ 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.23.1 - 03.07.2021
### Fixed
- Fixed the investment chart (drafts)
## 1.23.0 - 03.07.2021
### Added
- Added support for future transactions (drafts)
## 1.22.0 - 25.06.2021
### Added
- Set the user id in the _Stripe_ callback
## 1.21.0 - 22.06.2021
### Changed
- Changed _Stripe_ mode from `subscription` to `payment`
### Fixed
- Fixed the base currency on the pricing page
## 1.20.0 - 21.06.2021
### Added
- Set up _Stripe_ for subscriptions
### Changed
- Improved the style of the _Ghostfolio in Numbers_ section
## 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 ## 1.12.0 - 06.06.2021
### Added ### Added
- Added a symbol profile model with additional data - Added a symbol profile model with additional data
- Added new pie charts: Positions by continent and country - Added new pie charts: Investments by continent and country
## 1.11.0 - 05.06.2021 ## 1.11.0 - 05.06.2021
@ -84,11 +189,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added an index in the user table of the admin control panel - Added an index in the users table of the admin control panel
### Changed ### Changed
- Improved the alignment in the user table of the admin control panel - Improved the alignment in the users table of the admin control panel
## 1.5.0 - 22.05.2021 ## 1.5.0 - 22.05.2021
@ -220,7 +325,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
@ -228,7 +333,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
@ -255,7 +360,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
@ -286,7 +391,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
@ -301,7 +406,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the about page for the new license - Changed the about page for the new license
- Optimized the data management for historical data - Optimized the data management for historical data
- Optimized the exchange rate service - Optimized the exchange rate service
- Improved the user table of the admin control panel - Improved the users table of the admin control panel
### Fixed ### Fixed

View File

@ -8,8 +8,8 @@
</p> </p>
<p> <p>
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/> <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.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a> <img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></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"/></a> <img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
</p> </p>
@ -84,21 +84,29 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
## Development ## Development
Please make sure you have completed the instructions from [_Setup_](#Setup) 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
Run `yarn test` Run `yarn test`
## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
## License ## License
© 2021 [Ghostfolio](https://ghostfol.io) © 2021 [Ghostfolio](https://ghostfol.io)

View File

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

View File

@ -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';
@ -26,6 +27,7 @@ import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -34,6 +36,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
AuthDeviceModule,
AuthModule, AuthModule,
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
@ -57,6 +60,7 @@ import { UserModule } from './user/user.module';
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*'] exclude: ['/api*']
}), }),
SubscriptionModule,
SymbolModule, SymbolModule,
UserModule UserModule
], ],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,13 @@
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 { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions'; 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 +31,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,10 +44,59 @@ 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(),
subscriptions: await this.getSubscriptions()
}; };
} }
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;
}
}
private getDemoAuthToken() { private getDemoAuthToken() {
return this.jwtService.sign({ return this.jwtService.sign({
id: InfoService.DEMO_USER_ID id: InfoService.DEMO_USER_ID
@ -54,4 +110,36 @@ 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 getSubscriptions(): Promise<Subscription[]> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
const stripeConfig = await this.prisma.property.findUnique({
where: { key: 'STRIPE_CONFIG' }
});
if (stripeConfig) {
return [JSON.parse(stripeConfig.value)];
}
return [];
}
} }

View File

@ -68,7 +68,8 @@ export class OrderController {
public async getAllOrders( public async getAllOrders(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> { ): Promise<OrderModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId( const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
this.request.user.id this.request.user.id
); );
@ -132,12 +133,26 @@ export class OrderController {
return this.orderService.createOrder( return this.orderService.createOrder(
{ {
...data, ...data,
date,
Account: { Account: {
connect: { connect: {
id_userId: { id: accountId, userId: this.request.user.id } id_userId: { id: accountId, userId: this.request.user.id }
} }
}, },
date,
SymbolProfile: {
connectOrCreate: {
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } }
}, },
this.request.user.id this.request.user.id

View File

@ -3,6 +3,7 @@ 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 { DataSource, Order, Prisma } from '@prisma/client'; import { DataSource, Order, Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
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';
@ -50,7 +51,8 @@ export class OrderService {
): Promise<Order> { ): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`); this.redisCacheService.remove(`${aUserId}.portfolio`);
// Gather symbol data of order in the background if (!isAfter(data.date as Date, endOfToday())) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: data.dataSource, dataSource: data.dataSource,
@ -58,6 +60,7 @@ export class OrderService {
symbol: data.symbol symbol: data.symbol
} }
]); ]);
}
await this.cacheService.flush(aUserId); await this.cacheService.flush(aUserId);

View File

@ -14,6 +14,7 @@ import { REQUEST } from '@nestjs/core';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import {
add, add,
endOfToday,
format, format,
getDate, getDate,
getMonth, getMonth,
@ -52,7 +53,7 @@ export class PortfolioService {
public async createPortfolio(aUserId: string): Promise<Portfolio> { public async createPortfolio(aUserId: string): Promise<Portfolio> {
let portfolio: Portfolio; let portfolio: Portfolio;
let stringifiedPortfolio = await this.redisCacheService.get( const stringifiedPortfolio = await this.redisCacheService.get(
`${aUserId}.portfolio` `${aUserId}.portfolio`
); );
@ -63,9 +64,8 @@ export class PortfolioService {
const { const {
orders, orders,
portfolioItems portfolioItems
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = JSON.parse( }: { orders: IOrder[]; portfolioItems: PortfolioItem[] } =
stringifiedPortfolio JSON.parse(stringifiedPortfolio);
);
portfolio = new Portfolio( portfolio = new Portfolio(
this.dataProviderService, this.dataProviderService,
@ -104,12 +104,18 @@ export class PortfolioService {
} }
// Enrich portfolio with current data // Enrich portfolio with current data
return await portfolio.addCurrentPortfolioItems(); await portfolio.addCurrentPortfolioItems();
// Enrich portfolio with future data
await portfolio.addFuturePortfolioItems();
return portfolio;
} }
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> { public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
try { try {
const impersonationUserId = await this.impersonationService.validateImpersonationId( const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId, aImpersonationId,
this.request.user.id this.request.user.id
); );
@ -127,7 +133,8 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> { ): Promise<HistoricalDataItem[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId( const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId, aImpersonationId,
this.request.user.id this.request.user.id
); );
@ -148,6 +155,11 @@ export class PortfolioService {
return portfolio return portfolio
.get() .get()
.filter((portfolioItem) => { .filter((portfolioItem) => {
if (isAfter(parseISO(portfolioItem.date), endOfToday())) {
// Filter out future dates
return false;
}
if (dateRangeDate === undefined) { if (dateRangeDate === undefined) {
return true; return true;
} }
@ -170,7 +182,8 @@ export class PortfolioService {
public async getOverview( public async getOverview(
aImpersonationId: string aImpersonationId: string
): Promise<PortfolioOverview> { ): Promise<PortfolioOverview> {
const impersonationUserId = await this.impersonationService.validateImpersonationId( const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId, aImpersonationId,
this.request.user.id this.request.user.id
); );
@ -195,7 +208,8 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const impersonationUserId = await this.impersonationService.validateImpersonationId( const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId, aImpersonationId,
this.request.user.id this.request.user.id
); );
@ -318,7 +332,7 @@ export class PortfolioService {
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
for (const [date, { marketPrice, performance }] of Object.entries( for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol] historicalData[aSymbol]
).reverse()) { ).reverse()) {
historicalDataArray.push({ historicalDataArray.push({

View File

@ -0,0 +1,57 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Post,
Req,
Res,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service';
@Controller('subscription')
export class SubscriptionController {
public constructor(
private readonly configurationService: ConfigurationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService
) {}
@Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) {
await this.subscriptionService.createSubscription(
req.query.checkoutSessionId
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
}
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'))
public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string }
) {
try {
return await this.subscriptionService.createCheckoutSession({
couponId,
priceId,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,13 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service';
@Module({
imports: [],
controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService]
})
export class SubscriptionModule {}

View File

@ -0,0 +1,89 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { addDays } from 'date-fns';
import Stripe from 'stripe';
@Injectable()
export class SubscriptionService {
private stripe: Stripe;
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
) {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2020-08-27'
}
);
}
public async createCheckoutSession({
couponId,
priceId,
userId
}: {
couponId?: string;
priceId: string;
userId: string;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
client_reference_id: userId,
line_items: [
{
price: priceId,
quantity: 1
}
],
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) {
checkoutSessionCreateParams.discounts = [
{
coupon: couponId
}
];
}
const session = await this.stripe.checkout.sessions.create(
checkoutSessionCreateParams
);
return {
sessionId: session.id
};
}
public async createSubscription(aCheckoutSessionId: string) {
try {
const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId
);
await this.prisma.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: session.client_reference_id
}
}
}
});
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
});
} catch (error) {
console.error(error);
}
}
}

View File

@ -0,0 +1,7 @@
import { Currency, ViewMode } from '@prisma/client';
export interface UserSettingsParams {
currency?: Currency;
userId: string;
viewMode?: ViewMode;
}

View File

@ -25,6 +25,7 @@ import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UpdateUserSettingsDto } from './update-user-settings.dto'; import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -92,10 +93,20 @@ export class UserController {
); );
} }
return await this.userService.updateUserSettings({ const userSettings: UserSettingsParams = {
currency: data.baseCurrency, currency: data.baseCurrency,
userId: this.request.user.id, userId: this.request.user.id
viewMode: data.viewMode };
});
if (
hasPermission(
getPermissions(this.request.user.role),
permissions.updateViewMode
)
) {
userSettings.viewMode = data.viewMode;
}
return await this.userService.updateUserSettings(userSettings);
} }
} }

View File

@ -1,13 +1,14 @@
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 { locale } from '@ghostfolio/common/config'; import { locale } from '@ghostfolio/common/config';
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 { 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, isBefore } from 'date-fns'; import { isBefore } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
const crypto = require('crypto'); const crypto = require('crypto');
@ -24,7 +25,7 @@ export class UserService {
Account, Account,
alias, alias,
id, id,
role, permissions,
Settings, Settings,
subscription subscription
}: UserWithSettings): Promise<IUser> { }: UserWithSettings): Promise<IUser> {
@ -36,15 +37,10 @@ export class UserService {
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
const currentPermissions = getPermissions(role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
return { return {
alias, alias,
id, id,
permissions,
subscription, subscription,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
@ -53,7 +49,6 @@ export class UserService {
}; };
}), }),
accounts: Account, accounts: Account,
permissions: currentPermissions,
settings: { settings: {
locale, locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
@ -72,6 +67,14 @@ export class UserService {
const user: UserWithSettings = userFromDatabase; const user: UserWithSettings = userFromDatabase;
const currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
user.permissions = currentPermissions;
if (userFromDatabase?.Settings) { if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) { if (!userFromDatabase.Settings.currency) {
// Set default currency if needed // Set default currency if needed
@ -106,6 +109,13 @@ export class UserService {
type: SubscriptionType.Basic type: SubscriptionType.Basic
}; };
} }
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {
return permission !== permissions.updateViewMode;
});
user.Settings.viewMode = ViewMode.ZEN;
}
} }
return user; return user;
@ -213,11 +223,7 @@ export class UserService {
currency, currency,
userId, userId,
viewMode viewMode
}: { }: UserSettingsParams) {
currency?: Currency;
userId: string;
viewMode?: ViewMode;
}) {
await this.prisma.settings.upsert({ await this.prisma.settings.upsert({
create: { create: {
currency, currency,

View File

@ -1,4 +1,5 @@
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client'; import { Account, Currency, SymbolProfile } from '@prisma/client';
import { endOfToday, isAfter, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
@ -52,6 +53,10 @@ export class Order {
return this.id; return this.id;
} }
public getIsDraft() {
return isAfter(parseISO(this.date), endOfToday());
}
public getQuantity() { public getQuantity() {
return this.quantity; return this.quantity;
} }

View File

@ -120,6 +120,7 @@ describe('Portfolio', () => {
} }
], ],
alias: 'Test', alias: 'Test',
authChallenge: null,
createdAt: new Date(), createdAt: new Date(),
id: USER_ID, id: USER_ID,
provider: null, provider: null,
@ -274,7 +275,9 @@ describe('Portfolio', () => {
expect(portfolio.getPositions(getYesterday())).toMatchObject({}); expect(portfolio.getPositions(getYesterday())).toMatchObject({});
expect(portfolio.getSymbols(getYesterday())).toEqual(['BTCUSD']); expect(portfolio.getSymbols(getYesterday())).toEqual([]);
expect(portfolio.getSymbols(new Date())).toEqual(['BTCUSD']);
}); });
}); });
@ -308,16 +311,16 @@ describe('Portfolio', () => {
) )
); );
const details = await portfolio.getDetails('1d'); /*const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({ expect(details).toMatchObject({
ETHUSD: { ETHUSD: {
accounts: { accounts: {
[UNKNOWN_KEY]: { [UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency( current: exchangeRateDataService.toCurrency(
0.2 * 991.49, 0.2 * 991.49,
Currency.USD, Currency.USD,
baseCurrency baseCurrency
),*/ ),
original: exchangeRateDataService.toCurrency( original: exchangeRateDataService.toCurrency(
0.2 * 991.49, 0.2 * 991.49,
Currency.USD, Currency.USD,
@ -344,7 +347,7 @@ describe('Portfolio', () => {
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: 'Cryptocurrency' type: 'Cryptocurrency'
} }
}); });*/
expect(portfolio.getFees()).toEqual(0); expect(portfolio.getFees()).toEqual(0);

View File

@ -9,6 +9,7 @@ import {
UserWithSettings UserWithSettings
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; 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 { Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
@ -72,7 +73,7 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsYesterday] = this.get(yesterday); const [portfolioItemsYesterday] = this.get(yesterday);
let positions: { [symbol: string]: Position } = {}; const positions: { [symbol: string]: Position } = {};
this.getSymbols().forEach((symbol) => { this.getSymbols().forEach((symbol) => {
positions[symbol] = { positions[symbol] = {
@ -104,14 +105,45 @@ export class Portfolio implements PortfolioInterface {
); );
// Set value after pushing today's portfolio items // Set value after pushing today's portfolio items
this.portfolioItems[portfolioItemsLength - 1].value = this.getValue( this.portfolioItems[portfolioItemsLength - 1].value =
today this.getValue(today);
);
} }
return this; return this;
} }
public async addFuturePortfolioItems() {
let investment = this.getInvestment(new Date());
this.getOrders()
.filter((order) => order.getIsDraft() === true)
.forEach((order) => {
investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
const portfolioItem = this.portfolioItems.find((item) => {
return item.date === order.getDate();
});
if (portfolioItem) {
portfolioItem.investment = investment;
} else {
this.portfolioItems.push({
investment,
date: order.getDate(),
grossPerformancePercent: 0,
positions: {},
value: 0
});
}
});
return this;
}
public createFromData({ public createFromData({
orders, orders,
portfolioItems, portfolioItems,
@ -177,6 +209,8 @@ export class Portfolio implements PortfolioInterface {
if (filteredPortfolio) { if (filteredPortfolio) {
return [cloneDeep(filteredPortfolio)]; return [cloneDeep(filteredPortfolio)];
} }
return [];
} }
return cloneDeep(this.portfolioItems); return cloneDeep(this.portfolioItems);
@ -210,6 +244,7 @@ export class Portfolio implements PortfolioInterface {
symbols.forEach((symbol) => { symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {}; const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[]; let countriesOfSymbol: Country[];
let sectorsOfSymbol: Sector[];
const [portfolioItem] = portfolioItems; const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => { const ordersBySymbol = this.getOrders().filter((order) => {
@ -237,12 +272,10 @@ export class Portfolio implements PortfolioInterface {
if ( if (
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
) { ) {
accounts[ accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].current +=
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY currentValueOfSymbol;
].current += currentValueOfSymbol; accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].original +=
accounts[ originalValueOfSymbol;
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
].original += originalValueOfSymbol;
} else { } else {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = { accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol, current: currentValueOfSymbol,
@ -264,12 +297,23 @@ export class Portfolio implements PortfolioInterface {
weight: weight as number 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;
// 1d // 1d
let before = portfolioItemsBefore.positions[symbol].marketPrice; let before = portfolioItemsBefore?.positions[symbol].marketPrice;
if (aDateRange === 'ytd') { if (aDateRange === 'ytd') {
before = before =
@ -286,7 +330,7 @@ export class Portfolio implements PortfolioInterface {
if ( if (
!isBefore( !isBefore(
parseISO(portfolioItemsNow.positions[symbol].firstBuyDate), parseISO(portfolioItemsNow.positions[symbol].firstBuyDate),
parseISO(portfolioItemsBefore.date) parseISO(portfolioItemsBefore?.date)
) )
) { ) {
// Trade was not before the date of portfolioItemsBefore, then override it with average price // Trade was not before the date of portfolioItemsBefore, then override it with average price
@ -318,6 +362,7 @@ 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,
sectors: sectorsOfSymbol,
transactionCount: portfolioItem.positions[symbol].transactionCount, transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now, portfolioItem.positions[symbol].quantity * now,
@ -351,7 +396,11 @@ export class Portfolio implements PortfolioInterface {
} }
public getMinDate() { public getMinDate() {
if (this.orders.length > 0) { const orders = this.getOrders().filter(
(order) => order.getIsDraft() === false
);
if (orders.length > 0) {
return new Date(this.orders[0].getDate()); return new Date(this.orders[0].getDate());
} }
@ -478,7 +527,9 @@ export class Portfolio implements PortfolioInterface {
} }
} }
} else { } else {
symbols = this.orders.map((order) => { symbols = this.orders
.filter((order) => order.getIsDraft() === false)
.map((order) => {
return order.getSymbol(); return order.getSymbol();
}); });
} }
@ -489,7 +540,9 @@ export class Portfolio implements PortfolioInterface {
public getTotalBuy() { public getTotalBuy() {
return this.orders return this.orders
.filter((order) => order.getType() === 'BUY') .filter(
(order) => order.getIsDraft() === false && order.getType() === 'BUY'
)
.map((order) => { .map((order) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.getTotal(), order.getTotal(),
@ -502,7 +555,9 @@ export class Portfolio implements PortfolioInterface {
public getTotalSell() { public getTotalSell() {
return this.orders return this.orders
.filter((order) => order.getType() === 'SELL') .filter(
(order) => order.getIsDraft() === false && order.getType() === 'SELL'
)
.map((order) => { .map((order) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.getTotal(), order.getTotal(),
@ -672,10 +727,10 @@ export class Portfolio implements PortfolioInterface {
this.portfolioItems.push( this.portfolioItems.push(
cloneDeep({ cloneDeep({
positions,
date: yesterday.toISOString(), date: yesterday.toISOString(),
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: 0, investment: 0,
positions: positions,
value: 0 value: 0
}) })
); );
@ -732,8 +787,6 @@ export class Portfolio implements PortfolioInterface {
} }
private updatePortfolioItems() { private updatePortfolioItems() {
// console.time('update-portfolio-items');
let currentDate = new Date(); let currentDate = new Date();
const year = getYear(currentDate); const year = getYear(currentDate);
@ -757,6 +810,7 @@ export class Portfolio implements PortfolioInterface {
} }
this.orders.forEach((order) => { this.orders.forEach((order) => {
if (order.getIsDraft() === false) {
let index = this.portfolioItems.findIndex((item) => { let index = this.portfolioItems.findIndex((item) => {
const dateOfOrder = setDate(parseISO(order.getDate()), 1); const dateOfOrder = setDate(parseISO(order.getDate()), 1);
return isSameDay(parseISO(item.date), dateOfOrder); return isSameDay(parseISO(item.date), dateOfOrder);
@ -769,9 +823,8 @@ export class Portfolio implements PortfolioInterface {
for (let i = index; i < this.portfolioItems.length; i++) { for (let i = index; i < this.portfolioItems.length; i++) {
// Set currency // Set currency
this.portfolioItems[i].positions[ this.portfolioItems[i].positions[order.getSymbol()].currency =
order.getSymbol() order.getCurrency();
].currency = order.getCurrency();
this.portfolioItems[i].positions[ this.portfolioItems[i].positions[
order.getSymbol() order.getSymbol()
@ -781,19 +834,14 @@ export class Portfolio implements PortfolioInterface {
if ( if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate !this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
) { ) {
this.portfolioItems[i].positions[ this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate =
order.getSymbol() resetHours(parseISO(order.getDate())).toISOString();
].firstBuyDate = resetHours(
parseISO(order.getDate())
).toISOString();
} }
this.portfolioItems[i].positions[ this.portfolioItems[i].positions[order.getSymbol()].quantity +=
order.getSymbol() order.getQuantity();
].quantity += order.getQuantity(); this.portfolioItems[i].positions[order.getSymbol()].investment +=
this.portfolioItems[i].positions[ this.exchangeRateDataService.toCurrency(
order.getSymbol()
].investment += this.exchangeRateDataService.toCurrency(
order.getTotal(), order.getTotal(),
order.getCurrency(), order.getCurrency(),
this.user.Settings.currency this.user.Settings.currency
@ -802,29 +850,28 @@ export class Portfolio implements PortfolioInterface {
order.getSymbol() order.getSymbol()
].investmentInOriginalCurrency += order.getTotal(); ].investmentInOriginalCurrency += order.getTotal();
this.portfolioItems[ this.portfolioItems[i].investment +=
i this.exchangeRateDataService.toCurrency(
].investment += this.exchangeRateDataService.toCurrency(
order.getTotal(), order.getTotal(),
order.getCurrency(), order.getCurrency(),
this.user.Settings.currency this.user.Settings.currency
); );
} else if (order.getType() === 'SELL') { } else if (order.getType() === 'SELL') {
this.portfolioItems[i].positions[ this.portfolioItems[i].positions[order.getSymbol()].quantity -=
order.getSymbol() order.getQuantity();
].quantity -= order.getQuantity();
if ( if (
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0 this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
) { ) {
this.portfolioItems[i].positions[order.getSymbol()].investment = 0; this.portfolioItems[i].positions[
order.getSymbol()
].investment = 0;
this.portfolioItems[i].positions[ this.portfolioItems[i].positions[
order.getSymbol() order.getSymbol()
].investmentInOriginalCurrency = 0; ].investmentInOriginalCurrency = 0;
} else { } else {
this.portfolioItems[i].positions[ this.portfolioItems[i].positions[order.getSymbol()].investment -=
order.getSymbol() this.exchangeRateDataService.toCurrency(
].investment -= this.exchangeRateDataService.toCurrency(
order.getTotal(), order.getTotal(),
order.getCurrency(), order.getCurrency(),
this.user.Settings.currency this.user.Settings.currency
@ -834,9 +881,8 @@ export class Portfolio implements PortfolioInterface {
].investmentInOriginalCurrency -= order.getTotal(); ].investmentInOriginalCurrency -= order.getTotal();
} }
this.portfolioItems[ this.portfolioItems[i].investment -=
i this.exchangeRateDataService.toCurrency(
].investment -= this.exchangeRateDataService.toCurrency(
order.getTotal(), order.getTotal(),
order.getCurrency(), order.getCurrency(),
this.user.Settings.currency this.user.Settings.currency
@ -856,8 +902,7 @@ export class Portfolio implements PortfolioInterface {
currentValue / this.portfolioItems[i].investment - 1 || 0; currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue; this.portfolioItems[i].value = currentValue;
} }
}
}); });
// console.timeEnd('update-portfolio-items');
} }
} }

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@ -17,6 +17,7 @@ export class ConfigurationService {
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
@ -26,7 +27,9 @@ 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' }),
STRIPE_SECRET_KEY: str({ default: '' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
}); });
} }

View File

@ -8,6 +8,7 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
endOfToday,
format, format,
getDate, getDate,
getMonth, getMonth,
@ -187,7 +188,8 @@ export class DataGatheringService {
public async getCustomSymbolsToGather( public async getCustomSymbolsToGather(
startDate?: Date startDate?: Date
): Promise<IDataGatheringItem[]> { ): Promise<IDataGatheringItem[]> {
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations(); const scraperConfigurations =
await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => { return scraperConfigurations.map((scraperConfiguration) => {
return { return {
@ -224,7 +226,12 @@ export class DataGatheringService {
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: { dataSource: true, symbol: true } select: { dataSource: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
}); });
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
@ -280,7 +287,12 @@ export class DataGatheringService {
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: { dataSource: true, date: true, symbol: true } select: { dataSource: true, date: true, symbol: true },
where: {
date: {
lt: endOfToday() // no draft
}
}
}); });
return [ return [

View File

@ -12,9 +12,7 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
Industry,
MarketState, MarketState,
Sector,
Type Type
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { import {
@ -70,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;
@ -228,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;
@ -291,6 +230,6 @@ export class YahooFinanceService implements DataProviderInterface {
} }
export const convertFromYahooSymbol = (aSymbol: string) => { export const convertFromYahooSymbol = (aSymbol: string) => {
let symbol = aSymbol.replace('-', ''); const symbol = aSymbol.replace('-', '');
return symbol.replace('=X', ''); return symbol.replace('=X', '');
}; };

View File

@ -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,6 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PORT: number; REDIS_PORT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_SECRET_KEY: string;
WEB_AUTH_RP_ID: string;
} }

View File

@ -3,29 +3,12 @@ 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',
@ -55,13 +38,11 @@ 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;
} }
@ -72,10 +53,6 @@ export interface IDataGatheringItem {
symbol: string; symbol: string;
} }
export type Industry = typeof Industry[keyof typeof Industry];
export type MarketState = typeof MarketState[keyof typeof MarketState]; export type MarketState = typeof MarketState[keyof typeof MarketState];
export type Sector = typeof Sector[keyof typeof Sector];
export type Type = typeof Type[keyof typeof Type]; export type Type = typeof Type[keyof typeof Type];

View File

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

View File

@ -92,6 +92,13 @@ const routes: Routes = [
(m) => m.TransactionsPageModule (m) => m.TransactionsPageModule
) )
}, },
{
path: 'webauthn',
loadChildren: () =>
import('./pages/webauthn/webauthn-page.module').then(
(m) => m.WebauthnPageModule
)
},
{ {
path: 'zen', path: 'zen',
loadChildren: () => loadChildren: () =>

View File

@ -15,7 +15,9 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialCssVarsModule } from 'angular-material-css-vars'; import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown'; import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule } from 'ngx-stripe';
import { environment } from '../environments/environment';
import { CustomDateAdapter } from './adapter/custom-date-adapter'; import { CustomDateAdapter } from './adapter/custom-date-adapter';
import { DateFormats } from './adapter/date-formats'; import { DateFormats } from './adapter/date-formats';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
@ -43,7 +45,8 @@ import { LanguageService } from './core/language.service';
}), }),
MatNativeDateModule, MatNativeDateModule,
MatSnackBarModule, MatSnackBarModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule,
NgxStripeModule.forRoot(environment.stripePublicKey)
], ],
providers: [ providers: [
authInterceptorProviders, authInterceptorProviders,

View File

@ -3,6 +3,11 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>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>
@ -49,8 +54,10 @@
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
<th *matHeaderCellDef i18n mat-header-cell>Transactions</th> <th *matHeaderCellDef class="text-right" i18n mat-header-cell>
<td *matCellDef="let element" mat-cell> Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }} {{ element.Order?.length }}
</td> </td>
</ng-container> </ng-container>

View File

@ -11,6 +11,10 @@ import { Router } from '@angular/router';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/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';
@ -42,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
@ -87,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'
}); });
@ -112,7 +118,10 @@ export class HeaderComponent implements OnChanges {
} }
public setToken(aToken: string) { public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken); this.tokenStorageService.saveToken(
aToken,
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
);
this.router.navigate(['/']); this.router.navigate(['/']);
} }

View File

@ -2,12 +2,12 @@
*ngIf="isLoading" *ngIf="isLoading"
animation="pulse" animation="pulse"
[theme]="{ [theme]="{
height: '12rem', height: '100%',
width: '100%' width: '100%'
}" }"
></ngx-skeleton-loader> ></ngx-skeleton-loader>
<canvas <canvas
#chartCanvas #chartCanvas
height="50" class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }" [ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas> ></canvas>

View File

@ -19,6 +19,7 @@ import {
TimeScale TimeScale
} from 'chart.js'; } from 'chart.js';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js';
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
@Component({ @Component({
selector: 'gf-investment-chart', selector: 'gf-investment-chart',
@ -52,9 +53,30 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
} }
} }
public ngOnDestroy() {
this.chart?.destroy();
}
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
if (this.portfolioItems?.length > 0) {
// Extend chart by three months (before)
const firstItem = this.portfolioItems[0];
this.portfolioItems.unshift({
...firstItem,
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
investment: 0
});
// Extend chart by three months (after)
const lastItem = this.portfolioItems[this.portfolioItems.length - 1];
this.portfolioItems.push({
...lastItem,
date: addMonths(parseISO(lastItem.date), 3).toISOString()
});
}
const data = { const data = {
labels: this.portfolioItems.map((position) => { labels: this.portfolioItems.map((position) => {
return position.date; return position.date;
@ -65,7 +87,16 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
borderWidth: 2, borderWidth: 2,
data: this.portfolioItems.map((position) => { data: this.portfolioItems.map((position) => {
return position.investment; return position.investment;
}) }),
segment: {
borderColor: (context: unknown) =>
this.isInFuture(
context,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
),
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
},
stepped: true
} }
] ]
}; };
@ -123,7 +154,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
} }
} }
public ngOnDestroy() { private isInFuture(aContext: any, aValue: any) {
this.chart?.destroy(); return isAfter(new Date(aContext?.p0?.parsed?.x), new Date())
? aValue
: undefined;
} }
} }

View File

@ -1,5 +1,10 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog'; 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({ @Component({
selector: 'gf-login-with-access-token-dialog', selector: 'gf-login-with-access-token-dialog',
@ -8,7 +13,22 @@ import { MAT_DIALOG_DATA } from '@angular/material/dialog';
templateUrl: 'login-with-access-token-dialog.html' templateUrl: 'login-with-access-token-dialog.html'
}) })
export class LoginWithAccessTokenDialog { export class LoginWithAccessTokenDialog {
public constructor(@Inject(MAT_DIALOG_DATA) public data: any) {} public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
private settingsStorageService: SettingsStorageService
) {}
ngOnInit() {} ngOnInit() {}
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
this.settingsStorageService.setSetting(
STAY_SIGNED_IN,
aValue.checked?.toString()
);
}
public onClose() {
this.dialogRef.close();
}
} }

View File

@ -1,4 +1,9 @@
<h1 mat-dialog-title i18n>Sign in</h1> <gf-dialog-header
mat-dialog-title
[title]="data.title"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div mat-dialog-content> <div mat-dialog-content>
<div> <div>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin"> <ng-container *ngIf="data.hasPermissionToUseSocialLogin">
@ -21,8 +26,13 @@
</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">
<mat-checkbox i18n (change)="onChangeStaySignedIn($event)"
>Stay signed in</mat-checkbox
>
</div>
<div>
<button <button
color="primary" color="primary"
i18n i18n
@ -33,3 +43,4 @@
Sign in Sign in
</button> </button>
</div> </div>
</div>

View File

@ -3,10 +3,12 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component'; import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';
@NgModule({ @NgModule({
@ -15,7 +17,9 @@ import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.com
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfDialogHeaderModule,
MatButtonModule, MatButtonModule,
MatCheckboxModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,

View File

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

View File

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

View File

@ -24,11 +24,13 @@ import { Chart } from 'chart.js';
styleUrls: ['./portfolio-proportion-chart.component.scss'] styleUrls: ['./portfolio-proportion-chart.component.scss']
}) })
export class PortfolioProportionChartComponent export class PortfolioProportionChartComponent
implements OnChanges, OnDestroy, OnInit { implements OnChanges, OnDestroy, OnInit
{
@Input() baseCurrency: Currency; @Input() baseCurrency: Currency;
@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 };
}; };
@ -71,9 +73,8 @@ export class PortfolioProportionChartComponent
Object.keys(this.positions).forEach((symbol) => { Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.key]) { if (this.positions[symbol][this.key]) {
if (chartData[this.positions[symbol][this.key]]) { if (chartData[this.positions[symbol][this.key]]) {
chartData[this.positions[symbol][this.key]].value += this.positions[ chartData[this.positions[symbol][this.key]].value +=
symbol this.positions[symbol].value;
].value;
} else { } else {
chartData[this.positions[symbol][this.key]] = { chartData[this.positions[symbol][this.key]] = {
value: this.positions[symbol].value value: this.positions[symbol].value
@ -90,12 +91,44 @@ 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) => {
if (unknownItem?.[1]) {
unknownItem[1] = {
value: unknownItem[1].value + restItem[1].value
};
}
});
// Sort data again
chartDataSorted = chartDataSorted
.sort((a, b) => {
return a[1].value - b[1].value;
})
.reverse();
}
chartDataSorted.forEach(([symbol, item], index) => { chartDataSorted.forEach(([symbol, item], index) => {
if (this.colorMap[symbol]) { if (this.colorMap[symbol]) {
// Reuse color // Reuse color

View File

@ -40,47 +40,51 @@
mat-table mat-table
[dataSource]="dataSource" [dataSource]="dataSource"
> >
<ng-container matColumnDef="date"> <ng-container matColumnDef="count">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-center px-1" class="d-none d-lg-table-cell px-1 text-right"
i18n i18n
mat-header-cell mat-header-cell
mat-sort-header
> >
#
</th>
<td
*matCellDef="let element; let i = index"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
{{ dataSource.data.length - i }}
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Date Date
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-center"> <div class="d-flex">
{{ element.date | date: defaultDateFormat }} {{ element.date | date: defaultDateFormat }}
</div> </div>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="type"> <ng-container matColumnDef="type">
<th <th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
*matHeaderCellDef
class="justify-content-center px-1"
i18n
mat-header-cell
mat-sort-header
>
Type Type
</th> </th>
<td mat-cell *matCellDef="let element" class="px-1 text-center"> <td mat-cell *matCellDef="let element" class="px-1">
<div <div
class="d-inline-flex justify-content-center pl-1 pr-2 py-1 type-badge" class="d-inline-flex p-1 type-badge"
[ngClass]="element.type == 'BUY' ? 'buy' : 'sell'" [ngClass]="element.type == 'BUY' ? 'buy' : 'sell'"
> >
<ion-icon <ion-icon
class="mr-1"
[name]=" [name]="
element.type === 'BUY' element.type === 'BUY'
? 'arrow-forward-circle-outline' ? 'arrow-forward-circle-outline'
: 'arrow-back-circle-outline' : 'arrow-back-circle-outline'
" "
></ion-icon> ></ion-icon>
<span>{{ element.type }}</span> <span class="d-none d-lg-block mx-1">{{ element.type }}</span>
</div> </div>
</td> </td>
</ng-container> </ng-container>
@ -90,24 +94,30 @@
Symbol Symbol
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex align-items-center">
{{ element.symbol | gfSymbol }} {{ element.symbol | gfSymbol }}
<span
*ngIf="isAfter(element.date, endOfToday)"
class="badge badge-secondary ml-1"
i18n
>Draft</span
>
</div>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="currency"> <ng-container matColumnDef="currency">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="d-none d-lg-table-cell justify-content-center px-1" class="d-none d-lg-table-cell px-1"
mat-header-cell
i18n i18n
mat-header-cell
mat-sort-header mat-sort-header
> >
Currency Currency
</th> </th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex justify-content-center">
{{ element.currency }} {{ element.currency }}
</div>
</td> </td>
</ng-container> </ng-container>
@ -175,7 +185,9 @@
</ng-container> </ng-container>
<ng-container matColumnDef="account"> <ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Account</th> <th *matHeaderCellDef class="px-1" mat-header-cell>
<span class="d-none d-lg-block" i18n>Account</span>
</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex"> <div class="d-flex">
<gf-symbol-icon <gf-symbol-icon

View File

@ -23,7 +23,7 @@ import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { format } from 'date-fns'; import { endOfToday, format, isAfter } from 'date-fns';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -39,7 +39,8 @@ const SEARCH_STRING_SEPARATOR = ',';
styleUrls: ['./transactions-table.component.scss'] styleUrls: ['./transactions-table.component.scss']
}) })
export class TransactionsTableComponent export class TransactionsTableComponent
implements OnChanges, OnDestroy, OnInit { implements OnChanges, OnDestroy, OnInit
{
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() locale: string; @Input() locale: string;
@ -54,11 +55,14 @@ export class TransactionsTableComponent
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>; @ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
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 endOfToday = endOfToday();
public filters$: Subject<string[]> = new BehaviorSubject([]); public filters$: Subject<string[]> = new BehaviorSubject([]);
public filters: Observable<string[]> = this.filters$.asObservable(); public filters: Observable<string[]> = this.filters$.asObservable();
public isAfter = isAfter;
public isLoading = true; public isLoading = true;
public placeholder = ''; public placeholder = '';
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
@ -133,6 +137,7 @@ export class TransactionsTableComponent
public ngOnChanges() { public ngOnChanges() {
this.displayedColumns = [ this.displayedColumns = [
'count',
'date', 'date',
'type', 'type',
'symbol', 'symbol',

View File

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

View File

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

View File

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

View File

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

View File

@ -40,7 +40,10 @@ export class AuthGuard implements CanActivate {
.get() .get()
.pipe( .pipe(
catchError(() => { catchError(() => {
if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) { if (route.queryParams?.utm_source) {
this.router.navigate(['/register']);
resolve(false);
} else if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
resolve(true); resolve(true);
return EMPTY; return EMPTY;
} else if (state.url !== '/start') { } else if (state.url !== '/start') {

View File

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

View File

@ -1,7 +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 { UserService } from '@ghostfolio/client/services/user/user.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';
@ -14,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,6 +31,7 @@ export class AboutPageComponent implements OnInit {
*/ */
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService private userService: UserService
) {} ) {}
@ -33,6 +39,19 @@ export class AboutPageComponent implements OnInit {
* Initializes the controller * Initializes the controller
*/ */
public ngOnInit() { public ngOnInit() {
this.dataService
.fetchInfo()
.subscribe(({ globalPermissions, statistics }) => {
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
this.statistics = statistics;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {

View File

@ -2,7 +2,7 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3> <h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<mat-card class="mb-3"> <mat-card>
<mat-card-content> <mat-card-content>
<p> <p>
<strong>Ghostfolio</strong> is open source software which empowers <strong>Ghostfolio</strong> is open source software which empowers
@ -17,21 +17,13 @@
</p> </p>
<p> <p>
If you encounter a bug or would like to suggest an improvement or a If you encounter a bug or would like to suggest an improvement or a
new feature, please open an issue at new feature, please tweet to
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>, tweet <a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>, send an
to <a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or e-mail to <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or open
send an e-mail to an issue at
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>. <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</p> </p>
<p class="text-center"> <p class="text-center">
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
<a <a
class="mx-2" class="mx-2"
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
@ -48,6 +40,14 @@
> >
<ion-icon name="mail" size="large"></ion-icon> <ion-icon name="mail" size="large"></ion-icon>
</a> </a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p> </p>
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<div <div
@ -60,10 +60,44 @@
</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>
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<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 my-2">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
{{ statistics?.activeUsers30d ?? '-' }}
</h3>
<div class="h6 mb-0">
Active Users <small class="text-muted">(Last 30 days)</small>
</div>
</div>
<div class="col-xs-12 col-md-4 my-2">
<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>
<mat-card class="changelog mb-3"> <mat-card class="changelog">
<mat-card-content> <mat-card-content>
<markdown [src]="'CHANGELOG.md'"></markdown> <markdown [src]="'CHANGELOG.md'"></markdown>
</mat-card-content> </mat-card-content>
@ -74,7 +108,7 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="mb-3 text-center" i18n>License</h3> <h3 class="mb-3 text-center" i18n>License</h3>
<mat-card class="mb-3"> <mat-card>
<mat-card-content> <mat-card-content>
<markdown [src]="'LICENSE'"></markdown> <markdown [src]="'LICENSE'"></markdown>
</mat-card-content> </mat-card-content>

View File

@ -1,12 +1,24 @@
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 { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { baseCurrency, 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 { StripeService } from 'ngx-stripe';
import { takeUntil } from 'rxjs/operators'; import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@Component({ @Component({
selector: 'gf-account-page', selector: 'gf-account-page',
@ -14,11 +26,20 @@ 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 = baseCurrency;
public coupon: number;
public couponId: string;
public currencies: Currency[] = []; public currencies: Currency[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public hasPermissionForSubscription;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public price: number;
public priceId: string;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -29,13 +50,27 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private userService: UserService private stripeService: StripeService,
private userService: UserService,
public webAuthnService: WebAuthnService
) { ) {
this.dataService this.dataService
.fetchInfo() .fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currencies }) => { .subscribe(({ currencies, globalPermissions, subscriptions }) => {
this.coupon = subscriptions?.[0]?.coupon;
this.couponId = subscriptions?.[0]?.couponId;
this.currencies = currencies; this.currencies = currencies;
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription
);
this.price = subscriptions?.[0]?.price;
this.priceId = subscriptions?.[0]?.priceId;
this.changeDetectorRef.markForCheck();
}); });
this.userService.stateChanged this.userService.stateChanged
@ -49,6 +84,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
permissions.updateUserSettings permissions.updateUserSettings
); );
this.hasPermissionToUpdateViewMode = hasPermission(
this.user.permissions,
permissions.updateViewMode
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -84,11 +124,74 @@ export class AccountPageComponent implements OnDestroy, OnInit {
}); });
} }
public onCheckout() {
this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
.pipe(
switchMap(({ sessionId }: { sessionId: string }) => {
return this.stripeService.redirectToCheckout({
sessionId
});
})
)
.subscribe((result) => {
if (result.error) {
alert(result.error.message);
}
});
}
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()
@ -96,6 +199,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
.subscribe((response) => { .subscribe((response) => {
this.accesses = response; this.accesses = response;
if (this.signInWithFingerprintElement) {
this.signInWithFingerprintElement.checked =
this.webAuthnService.isEnabled() ?? false;
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }

View File

@ -1,10 +1,7 @@
<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"> <h3 class="d-flex justify-content-center mb-3" i18n>Account</h3>
<ng-container *ngIf="user?.alias">{{ user.alias }}</ng-container>
<ng-container *ngIf="!user?.alias" i18n>Account</ng-container>
</h3>
</div> </div>
</div> </div>
<div *ngIf="user?.settings" class="mb-5 row"> <div *ngIf="user?.settings" class="mb-5 row">
@ -25,10 +22,26 @@
Valid until {{ user.subscription.expiresAt | date: Valid until {{ user.subscription.expiresAt | date:
defaultDateFormat }} defaultDateFormat }}
</div> </div>
<div *ngIf="!user.subscription.expiresAt"> <div
<button color="primary" disabled i18n mat-flat-button> *ngIf="hasPermissionForSubscription && !user.subscription.expiresAt"
>
<button
color="primary"
i18n
mat-flat-button
(click)="onCheckout(priceId)"
>
Upgrade Upgrade
</button> </button>
<div *ngIf="price" class="mt-1">
{{ baseCurrency }}
<ng-container *ngIf="coupon"
>{{ price - coupon | number : '1.2-2' }}
<del>{{ price }}</del>
</ng-container>
<ng-container *ngIf="!coupon">{{ price }}</ng-container>
<span i18n> per year</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -51,11 +64,12 @@
> >
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="w-100"> <div class="align-items-center d-flex overflow-hidden">
<mat-form-field appearance="outline" class="flex-grow-1">
<mat-label i18n>View Mode</mat-label> <mat-label i18n>View Mode</mat-label>
<mat-select <mat-select
name="viewMode" name="viewMode"
[disabled]="!hasPermissionToUpdateUserSettings" [disabled]="!hasPermissionToUpdateViewMode"
[value]="user.settings.viewMode" [value]="user.settings.viewMode"
(selectionChange)="onChangeUserSettings('viewMode', $event.value)" (selectionChange)="onChangeUserSettings('viewMode', $event.value)"
> >
@ -63,9 +77,26 @@
<mat-option value="ZEN">Zen</mat-option> <mat-option value="ZEN">Zen</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<ion-icon
*ngIf="!hasPermissionToUpdateViewMode"
class="h5 mb-0 mx-3 text-muted"
name="diamond-outline"
></ion-icon>
</div>
</form> </form>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="w-50" i18n>Sign in with fingerprint</div>
<div class="w-50">
<mat-slide-toggle
#toggleSignInWithFingerprintEnabledElement
color="primary"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onSignInWithFingerprintChange($event)"
></mat-slide-toggle>
</div>
</div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -3,8 +3,11 @@ import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { 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';
@ -20,8 +23,11 @@ import { AccountPageComponent } from './account-page.component';
GfPortfolioAccessTableModule, GfPortfolioAccessTableModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule,
MatSelectModule, MatSelectModule,
MatSlideToggleModule,
ReactiveFormsModule ReactiveFormsModule
], ],
providers: [] providers: []

View File

@ -6,7 +6,7 @@ import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-ta
import { AccountsPageRoutingModule } from './accounts-page-routing.module'; import { AccountsPageRoutingModule } from './accounts-page-routing.module';
import { AccountsPageComponent } from './accounts-page.component'; import { AccountsPageComponent } from './accounts-page.component';
import { CreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module'; import { GfCreateOrUpdateAccountDialogModule } from './create-or-update-account-dialog/create-or-update-account-dialog.module';
@NgModule({ @NgModule({
declarations: [AccountsPageComponent], declarations: [AccountsPageComponent],
@ -14,8 +14,8 @@ import { CreateOrUpdateAccountDialogModule } from './create-or-update-account-di
imports: [ imports: [
AccountsPageRoutingModule, AccountsPageRoutingModule,
CommonModule, CommonModule,
CreateOrUpdateAccountDialogModule,
GfAccountsTableModule, GfAccountsTableModule,
GfCreateOrUpdateAccountDialogModule,
MatButtonModule, MatButtonModule,
RouterModule RouterModule
], ],

View File

@ -1,6 +1,6 @@
<form #addAccountForm="ngForm" class="d-flex flex-column h-100"> <form #addAccountForm="ngForm" class="d-flex flex-column h-100">
<h1 *ngIf="data.account.id" mat-dialog-title i18n>Update account</h1> <h1 *ngIf="data.account.id" i18n mat-dialog-title>Update account</h1>
<h1 *ngIf="!data.account.id" mat-dialog-title i18n>Add account</h1> <h1 *ngIf="!data.account.id" i18n mat-dialog-title>Add account</h1>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">

View File

@ -24,4 +24,4 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
], ],
providers: [] providers: []
}) })
export class CreateOrUpdateAccountDialogModule {} export class GfCreateOrUpdateAccountDialogModule {}

View File

@ -5,7 +5,12 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.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 { formatDistanceToNowStrict, isValid, parseISO } 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';
@ -80,8 +85,8 @@ export class AdminPageComponent implements OnInit {
addSuffix: true addSuffix: true
}); });
return distanceString === 'in 0 seconds' || return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
distanceString === '0 seconds ago' 60
? 'just now' ? 'just now'
: distanceString; : distanceString;
} }

View File

@ -73,18 +73,18 @@
<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-center" i18n>#</th> <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 text-center" i18n> <th class="mat-header-cell px-1 py-2 text-right" i18n>
Registration Date Registration Date
</th> </th>
<th class="mat-header-cell px-1 py-2 text-center" i18n> <th class="mat-header-cell px-1 py-2 text-right" i18n>
Accounts Accounts
</th> </th>
<th class="mat-header-cell px-1 py-2 text-center" i18n> <th class="mat-header-cell px-1 py-2 text-right" i18n>
Transactions Transactions
</th> </th>
<th class="mat-header-cell px-1 py-2 text-center" i18n> <th class="mat-header-cell px-1 py-2 text-right" i18n>
Engagement Engagement
</th> </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>

View File

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

View File

@ -3,6 +3,7 @@ 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';
@ -255,7 +256,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
} }
public setToken(aToken: string) { public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken); this.tokenStorageService.saveToken(aToken, true);
this.router.navigate(['/']); this.router.navigate(['/']);
} }

View File

@ -1,4 +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 { 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';
@ -12,7 +13,9 @@ import { takeUntil } from 'rxjs/operators';
}) })
export class PricingPageComponent implements OnInit { export class PricingPageComponent implements OnInit {
public baseCurrency = baseCurrency; public baseCurrency = baseCurrency;
public coupon: number;
public isLoggedIn: boolean; public isLoggedIn: boolean;
public price: number;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -22,8 +25,19 @@ export class PricingPageComponent implements OnInit {
*/ */
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private userService: UserService private userService: UserService
) {} ) {
this.dataService
.fetchInfo()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ subscriptions }) => {
this.coupon = this.price = subscriptions?.[0]?.coupon;
this.price = subscriptions?.[0]?.price;
this.changeDetectorRef.markForCheck();
});
}
/** /**
* Initializes the controller * Initializes the controller

View File

@ -176,11 +176,17 @@
</ul> </ul>
</div> </div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p> <p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right"> <p class="h5 text-right" [hidden]="!price">
<span class="font-weight-normal" <span class="font-weight-normal"
>{{ user?.settings.baseCurrency || baseCurrency }} >{{ baseCurrency }}
<strong>0.00</strong> <ng-container *ngIf="coupon"
<del class="ml-1 text-muted">3.99</del> / Month</span ><strong>{{ price - coupon | number : '1.2-2' }} </strong>
<del>{{ price }}</del>
</ng-container>
<ng-container *ngIf="!coupon"
><strong>{{ price }}</strong></ng-container
>
<span i18n> per year</span></span
> >
</p> </p>
</mat-card> </mat-card>
@ -188,6 +194,13 @@
</div> </div>
</div> </div>
</div> </div>
<div *ngIf="user?.subscription?.type === 'Basic'" class="row">
<div class="col mt-3 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/account']">
Upgrade Plan
</a>
</div>
</div>
<div *ngIf="!user" class="row"> <div *ngIf="!user" class="row">
<div class="col mt-3 text-center"> <div class="col mt-3 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/register']"> <a color="primary" i18n mat-flat-button [routerLink]="['/register']">

View File

@ -78,19 +78,13 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
dialogRef.afterClosed().subscribe((data) => { dialogRef.afterClosed().subscribe((data) => {
if (data?.authToken) { if (data?.authToken) {
this.tokenStorageService.saveToken(authToken); this.tokenStorageService.saveToken(authToken, true);
this.router.navigate(['/']); this.router.navigate(['/']);
} }
}); });
} }
public setToken(aToken: string) {
this.tokenStorageService.saveToken(aToken);
this.router.navigate(['/']);
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -2,7 +2,7 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3 text-center" i18n> <h3 class="d-flex justify-content-center mb-3 text-center" i18n>
Create your Ghostfolio account Create your Account
</h3> </h3>
<mat-card class="mb-4"> <mat-card class="mb-4">
<mat-card-content class="text-center"> <mat-card-content class="text-center">

View File

@ -9,6 +9,7 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -39,6 +40,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public portfolioPositions: { [symbol: string]: PortfolioPosition }; public portfolioPositions: { [symbol: string]: PortfolioPosition };
public positions: { [symbol: string]: any }; public positions: { [symbol: string]: any };
public positionsArray: PortfolioPosition[]; public positionsArray: PortfolioPosition[];
public sectors: {
[name: string]: { name: string; value: number };
};
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -118,13 +122,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}; };
this.positions = {}; this.positions = {};
this.positionsArray = []; this.positionsArray = [];
this.sectors = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
for (const [symbol, position] of Object.entries(aPortfolioPositions)) { for (const [symbol, position] of Object.entries(aPortfolioPositions)) {
this.positions[symbol] = { this.positions[symbol] = {
currency: position.currency, currency: position.currency,
exchange: position.exchange, exchange: position.exchange,
industry: position.industry,
sector: position.sector,
type: position.type, type: position.type,
value: value:
aPeriod === 'original' aPeriod === 'original'
@ -188,6 +196,30 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
? this.portfolioPositions[symbol].investment ? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value; : this.portfolioPositions[symbol].value;
} }
if (position.sectors.length > 0) {
for (const sector of position.sectors) {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
this.sectors[name].value += weight * position.value;
} else {
this.sectors[name] = {
name,
value:
weight *
(aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value)
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
aPeriod === 'original'
? this.portfolioPositions[symbol].investment
: this.portfolioPositions[symbol].value;
}
} }
} }

View File

@ -61,7 +61,7 @@
<div class="col-md-6"> <div class="col-md-6">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="w-100">
<mat-card-title i18n>By Sector</mat-card-title> <mat-card-title i18n>By Currency</mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -71,7 +71,7 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="sector" key="currency"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -83,7 +83,7 @@
<div class="col-md-6"> <div class="col-md-6">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="w-100">
<mat-card-title i18n>By Industry</mat-card-title> <mat-card-title i18n>By Exchange</mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -93,7 +93,7 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="industry" key="exchange"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -102,6 +102,29 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Sector</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="name"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6"> <div class="col-md-6">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="w-100">
@ -141,88 +164,43 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false" [isInPercent]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="countries" [positions]="countries"
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Currency</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="currency"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div> </div>
<div class="col-md-6"> <div class="row world-map-chart">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Exchange</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="exchange"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="d-block d-sm-none row">
<div class="col-lg"> <div class="col-lg">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>Regions</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content> <mat-card-content>
<div class="d-flex align-items-center justify-content-center"> <gf-world-map-chart
<div class="mr-2"> [baseCurrency]="user?.settings?.baseCurrency"
<ion-icon [countries]="countries"
name="information-circle-outline" ></gf-world-map-chart>
size="small"
></ion-icon>
</div>
<div i18n>
You can find more charts on your desktop:
<a href="https://ghostfol.io" target="_blank">Ghostfol.io</a>
</div>
</div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
</div> </div>
<div class="d-none d-sm-block row"> <div class="investment-chart row">
<div class="col-lg"> <div class="col-lg">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n <mat-card-title class="align-items-center d-flex" i18n
>Investment</mat-card-title >Timeline</mat-card-title
> >
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<!--<gf-positions-chart
[portfolioItems]="portfolioItems"
></gf-positions-chart>-->
<gf-investment-chart <gf-investment-chart
[portfolioItems]="portfolioItems" [portfolioItems]="portfolioItems"
></gf-investment-chart> ></gf-investment-chart>

View File

@ -2,10 +2,10 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { PortfolioPositionsChartModule } from '@ghostfolio/client/components/portfolio-positions-chart/portfolio-positions-chart.module';
import { PortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { PortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module'; import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { AnalysisPageRoutingModule } from './analysis-page-routing.module'; import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
import { AnalysisPageComponent } from './analysis-page.component'; import { AnalysisPageComponent } from './analysis-page.component';
@ -19,8 +19,8 @@ import { AnalysisPageComponent } from './analysis-page.component';
GfInvestmentChartModule, GfInvestmentChartModule,
GfPositionsTableModule, GfPositionsTableModule,
GfToggleModule, GfToggleModule,
GfWorldMapChartModule,
MatCardModule, MatCardModule,
PortfolioPositionsChartModule,
PortfolioProportionChartModule PortfolioProportionChartModule
], ],
providers: [], providers: [],

View File

@ -1,4 +1,16 @@
:host { :host {
.investment-chart {
.mat-card {
.mat-card-content {
aspect-ratio: 16 / 9;
gf-investment-chart {
height: 100%;
}
}
}
}
.proportion-charts { .proportion-charts {
.mat-card { .mat-card {
.mat-card-content { .mat-card-content {
@ -7,6 +19,14 @@
} }
} }
.world-map-chart {
.mat-card {
.mat-card-content {
aspect-ratio: 16 / 9;
}
}
}
.mat-card { .mat-card {
.mat-card-header { .mat-card-header {
::ng-deep { ::ng-deep {

View File

@ -0,0 +1,11 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component';
const routes: Routes = [{ path: '', component: WebauthnPageComponent }];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class WebauthnPageRoutingModule {}

View File

@ -0,0 +1,46 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
@Component({
selector: 'gf-webauthn-page',
templateUrl: './webauthn-page.html',
styleUrls: ['./webauthn-page.scss']
})
export class WebauthnPageComponent implements OnInit {
public hasError = false;
constructor(
private changeDetectorRef: ChangeDetectorRef,
private router: Router,
private tokenStorageService: TokenStorageService,
private webAuthnService: WebAuthnService
) {}
public ngOnInit() {
this.signIn();
}
public deregisterDevice() {
this.webAuthnService.deregister().subscribe(() => {
this.router.navigate(['/']);
});
}
public signIn() {
this.hasError = false;
this.webAuthnService.login().subscribe(
({ authToken }) => {
this.tokenStorageService.saveToken(authToken, false);
this.router.navigate(['/']);
},
(error) => {
console.error(error);
this.hasError = true;
this.changeDetectorRef.markForCheck();
}
);
}
}

View File

@ -0,0 +1,36 @@
<div class="container">
<div class="row">
<div class="col">
<div
class="align-items-center d-flex flex-column justify-content-center mb-4 w-100"
>
<gf-logo size="medium"></gf-logo>
</div>
<div *ngIf="!hasError" class="col d-flex justify-content-center">
<mat-spinner [diameter]="20"></mat-spinner>
</div>
<div
*ngIf="hasError"
class="align-items-center col d-flex flex-column justify-content-center"
>
<h1 class="d-flex h5 justify-content-center mb-0 text-center" i18n>
Oops, authentication has failed.
</h1>
<button
class="mb-3 mt-4"
color="primary"
i18n
mat-flat-button
(click)="signIn()"
>
Try again
</button>
<div class="text-muted" i18n>or</div>
<button class="mt-1" i18n mat-flat-button (click)="deregisterDevice()">
Go back to Home Page
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,22 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component';
import { WebauthnPageRoutingModule } from './webauthn-page-routing.module';
@NgModule({
declarations: [WebauthnPageComponent],
exports: [],
imports: [
CommonModule,
GfLogoModule,
MatButtonModule,
MatProgressSpinnerModule,
WebauthnPageRoutingModule
],
providers: []
})
export class WebauthnPageModule {}

View File

@ -43,6 +43,19 @@ export class DataService {
private settingsStorageService: SettingsStorageService private settingsStorageService: SettingsStorageService
) {} ) {}
public createCheckoutSession({
couponId,
priceId
}: {
couponId?: string;
priceId: string;
}) {
return this.http.post('/api/subscription/stripe/checkout-session', {
couponId,
priceId
});
}
public fetchAccounts() { public fetchAccounts() {
return this.http.get<AccountModel[]>('/api/account'); return this.http.get<AccountModel[]>('/api/account');
} }

View File

@ -1,6 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
export const RANGE = 'range'; export const RANGE = 'range';
export const STAY_SIGNED_IN = 'staySignedIn';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -15,4 +16,8 @@ export class SettingsStorageService {
public setSetting(aKey: string, aValue: string) { public setSetting(aKey: string, aValue: string) {
window.localStorage.setItem(aKey, aValue); window.localStorage.setItem(aKey, aValue);
} }
public removeSetting(aKey: string): void {
return window.localStorage.removeItem(aKey);
}
} }

View File

@ -1,4 +1,5 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { UserService } from './user/user.service'; import { UserService } from './user/user.service';
@ -8,21 +9,34 @@ const TOKEN_KEY = 'auth-token';
providedIn: 'root' providedIn: 'root'
}) })
export class TokenStorageService { export class TokenStorageService {
public constructor(private userService: UserService) {} public constructor(
private userService: UserService,
private webAuthnService: WebAuthnService
) {}
public getToken(): string { public getToken(): string {
return window.localStorage.getItem(TOKEN_KEY); return (
window.sessionStorage.getItem(TOKEN_KEY) ||
window.localStorage.getItem(TOKEN_KEY)
);
} }
public saveToken(token: string): void { public saveToken(token: string, staySignedIn = false): void {
window.localStorage.removeItem(TOKEN_KEY); if (staySignedIn) {
window.localStorage.setItem(TOKEN_KEY, token); window.localStorage.setItem(TOKEN_KEY, token);
} }
window.sessionStorage.setItem(TOKEN_KEY, token);
}
public signOut(): void { public signOut(): void {
const utmSource = window.localStorage.getItem('utm_source'); const utmSource = window.localStorage.getItem('utm_source');
if (this.webAuthnService.isEnabled()) {
this.webAuthnService.deregister().subscribe();
}
window.localStorage.clear(); window.localStorage.clear();
window.sessionStorage.clear();
this.userService.remove(); this.userService.remove();

View File

@ -0,0 +1,104 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import {
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON
} from '@ghostfolio/api/app/auth/interfaces/simplewebauthn';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { startAssertion, startAttestation } from '@simplewebauthn/browser';
import { of } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class WebAuthnService {
private static readonly WEB_AUTH_N_DEVICE_ID = 'WEB_AUTH_N_DEVICE_ID';
public constructor(
private http: HttpClient,
private settingsStorageService: SettingsStorageService
) {}
public isSupported() {
return typeof PublicKeyCredential !== 'undefined';
}
public isEnabled() {
return !!this.getDeviceId();
}
public register() {
return this.http
.get<PublicKeyCredentialCreationOptionsJSON>(
`/api/auth/webauthn/generate-attestation-options`,
{}
)
.pipe(
catchError((error) => {
console.warn('Could not register device', error);
return of(null);
}),
switchMap((attOps) => {
return startAttestation(attOps);
}),
switchMap((attResp) => {
return this.http.post<AuthDeviceDto>(
`/api/auth/webauthn/verify-attestation`,
{
credential: attResp
}
);
}),
tap((authDevice) =>
this.settingsStorageService.setSetting(
WebAuthnService.WEB_AUTH_N_DEVICE_ID,
authDevice.id
)
)
);
}
public deregister() {
const deviceId = this.getDeviceId();
return this.http.delete<AuthDeviceDto>(`/api/auth-device/${deviceId}`).pipe(
catchError((error) => {
console.warn(`Could not deregister device ${deviceId}`, error);
return of(null);
}),
tap(() =>
this.settingsStorageService.removeSetting(
WebAuthnService.WEB_AUTH_N_DEVICE_ID
)
)
);
}
public login() {
const deviceId = this.getDeviceId();
return this.http
.post<PublicKeyCredentialRequestOptionsJSON>(
`/api/auth/webauthn/generate-assertion-options`,
{ deviceId }
)
.pipe(
switchMap(startAssertion),
switchMap((assertionResponse) => {
return this.http.post<{ authToken: string }>(
`/api/auth/webauthn/verify-assertion`,
{
credential: assertionResponse,
deviceId
}
);
})
);
}
private getDeviceId() {
return this.settingsStorageService.getSetting(
WebAuthnService.WEB_AUTH_N_DEVICE_ID
);
}
}

View File

@ -0,0 +1,3 @@
export function isNonNull<T>(value: T): value is NonNullable<T> {
return value != null;
}

View File

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

View File

@ -5,6 +5,7 @@
export const environment = { export const environment = {
lastPublish: null, lastPublish: null,
production: false, production: false,
stripePublicKey: '',
version: 'dev' version: 'dev'
}; };
@ -15,4 +16,4 @@ export const environment = {
* This import should be commented out in production mode because it will have a negative impact * This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown. * on performance if an error is thrown.
*/ */
// import 'zone.js/dist/zone-error'; // Included with Angular CLI. // import 'zone.js/plugins/zone-error'; // Included with Angular CLI.

View File

@ -55,7 +55,7 @@
/*************************************************************************************************** /***************************************************************************************************
* Zone JS is required by default for Angular itself. * Zone JS is required by default for Angular itself.
*/ */
import 'zone.js/dist/zone'; // Included with Angular CLI. import 'zone.js'; // Included with Angular CLI.
/*************************************************************************************************** /***************************************************************************************************
* APPLICATION IMPORTS * APPLICATION IMPORTS

View File

@ -4,6 +4,8 @@
@import '~angular-material-css-vars/main'; @import '~angular-material-css-vars/main';
@import '~svgmap/dist/svgMap';
$mat-css-dark-theme-selector: '.is-dark-theme'; $mat-css-dark-theme-selector: '.is-dark-theme';
$mat-css-light-theme-selector: '.is-light-theme'; $mat-css-light-theme-selector: '.is-light-theme';
@ -79,6 +81,14 @@ body {
color: rgba(var(--dark-primary-text)) !important; color: rgba(var(--dark-primary-text)) !important;
} }
} }
.svgMap-tooltip {
background: var(--dark-background);
.svgMap-tooltip-content table td span {
color: rgba(var(--light-primary-text));
}
}
} }
} }
@ -154,6 +164,14 @@ ngx-skeleton-loader {
min-width: unset !important; min-width: unset !important;
} }
.svgMap-tooltip {
border-bottom: none;
.svgMap-tooltip-pointer {
display: none;
}
}
.text-decoration-underline { .text-decoration-underline {
text-decoration: underline !important; text-decoration: underline !important;
} }

View File

@ -27,7 +27,7 @@
// @import '~bootstrap/scss/card'; // @import '~bootstrap/scss/card';
// @import '~bootstrap/scss/breadcrumb'; // @import '~bootstrap/scss/breadcrumb';
// @import '~bootstrap/scss/pagination'; // @import '~bootstrap/scss/pagination';
// @import '~bootstrap/scss/badge'; @import '~bootstrap/scss/badge';
// @import '~bootstrap/scss/jumbotron'; // @import '~bootstrap/scss/jumbotron';
// @import '~bootstrap/scss/alert'; // @import '~bootstrap/scss/alert';
// @import '~bootstrap/scss/progress'; // @import '~bootstrap/scss/progress';

View File

@ -1 +1 @@
import 'jest-preset-angular'; import 'jest-preset-angular/setup-jest';

View File

@ -2,9 +2,7 @@ module.exports = {
displayName: 'common', displayName: 'common',
preset: '../../jest.preset.js', preset: '../../jest.preset.js',
globals: { globals: {
'ts-jest': { 'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' }
tsConfig: '<rootDir>/tsconfig.spec.json'
}
}, },
transform: { transform: {
'^.+\\.[tj]sx?$': 'ts-jest' '^.+\\.[tj]sx?$': 'ts-jest'

View File

@ -2,7 +2,7 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export const baseCurrency = Currency.CHF; export const baseCurrency = Currency.USD;
export const benchmarks: Partial<IDataGatheringItem>[] = [ export const benchmarks: Partial<IDataGatheringItem>[] = [
{ dataSource: DataSource.YAHOO, symbol: 'VOO' } { dataSource: DataSource.YAHOO, symbol: 'VOO' }

View File

@ -1,5 +1,8 @@
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface';
export interface InfoItem { export interface InfoItem {
currencies: Currency[]; currencies: Currency[];
demoAuthToken: string; demoAuthToken: string;
@ -10,4 +13,6 @@ export interface InfoItem {
type: string; type: string;
}; };
platforms: { id: string; name: string }[]; platforms: { id: string; name: string }[];
statistics: Statistics;
subscriptions: Subscription[];
} }

View File

@ -2,6 +2,7 @@ import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Country } from './country.interface'; import { Country } from './country.interface';
import { Sector } from './sector.interface';
export interface PortfolioPosition { export interface PortfolioPosition {
accounts: { accounts: {
@ -14,7 +15,6 @@ export interface PortfolioPosition {
exchange?: string; exchange?: string;
grossPerformance: number; grossPerformance: number;
grossPerformancePercent: number; grossPerformancePercent: number;
industry?: string;
investment: number; investment: number;
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
@ -22,7 +22,7 @@ export interface PortfolioPosition {
marketState: MarketState; marketState: MarketState;
name: string; name: string;
quantity: number; quantity: number;
sector?: string; sectors: Sector[];
transactionCount: number; transactionCount: number;
symbol: string; symbol: string;
type?: string; type?: string;

View File

@ -0,0 +1,4 @@
export interface Sector {
name: string;
weight: number;
}

View File

@ -0,0 +1,5 @@
export interface Statistics {
activeUsers1d: number;
activeUsers30d: number;
gitHubStargazers: number;
}

View File

@ -0,0 +1,6 @@
export interface Subscription {
coupon?: number;
couponId?: string;
price: number;
priceId: string;
}

View File

@ -1,7 +1,7 @@
import { Currency, ViewMode } from '@prisma/client'; import { Currency, ViewMode } from '@prisma/client';
export interface UserSettings { export interface UserSettings {
baseCurrency: Currency; baseCurrency?: Currency;
locale: string; locale: string;
viewMode: ViewMode; viewMode?: ViewMode;
} }

View File

@ -3,6 +3,7 @@ import { Account, Settings, User } from '@prisma/client';
export type UserWithSettings = User & { export type UserWithSettings = User & {
Account: Account[]; Account: Account[];
permissions?: string[];
Settings: Settings; Settings: Settings;
subscription?: { subscription?: {
expiresAt?: Date; expiresAt?: Date;

View File

@ -11,14 +11,18 @@ export const permissions = {
createOrder: 'createOrder', createOrder: 'createOrder',
createUserAccount: 'createUserAccount', createUserAccount: 'createUserAccount',
deleteAccount: 'deleteAcccount', deleteAccount: 'deleteAcccount',
deleteAuthDevice: 'deleteAuthDevice',
deleteOrder: 'deleteOrder', deleteOrder: 'deleteOrder',
deleteUser: 'deleteUser', deleteUser: 'deleteUser',
enableSocialLogin: 'enableSocialLogin', enableSocialLogin: 'enableSocialLogin',
enableStatistics: 'enableStatistics',
enableSubscription: 'enableSubscription', enableSubscription: 'enableSubscription',
readForeignPortfolio: 'readForeignPortfolio', readForeignPortfolio: 'readForeignPortfolio',
updateAccount: 'updateAccount', updateAccount: 'updateAccount',
updateAuthDevice: 'updateAuthDevice',
updateOrder: 'updateOrder', updateOrder: 'updateOrder',
updateUserSettings: 'updateUserSettings' updateUserSettings: 'updateUserSettings',
updateViewMode: 'updateViewMode'
}; };
export function hasPermission( export function hasPermission(
@ -36,12 +40,15 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccount, permissions.createAccount,
permissions.createOrder, permissions.createOrder,
permissions.deleteAccount, permissions.deleteAccount,
permissions.deleteAuthDevice,
permissions.deleteOrder, permissions.deleteOrder,
permissions.deleteUser, permissions.deleteUser,
permissions.readForeignPortfolio, permissions.readForeignPortfolio,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAuthDevice,
permissions.updateOrder, permissions.updateOrder,
permissions.updateUserSettings permissions.updateUserSettings,
permissions.updateViewMode
]; ];
case 'DEMO': case 'DEMO':
@ -52,10 +59,13 @@ export function getPermissions(aRole: Role): string[] {
permissions.createAccount, permissions.createAccount,
permissions.createOrder, permissions.createOrder,
permissions.deleteAccount, permissions.deleteAccount,
permissions.deleteAuthDevice,
permissions.deleteOrder, permissions.deleteOrder,
permissions.updateAccount, permissions.updateAccount,
permissions.updateAuthDevice,
permissions.updateOrder, permissions.updateOrder,
permissions.updateUserSettings permissions.updateUserSettings,
permissions.updateViewMode
]; ];
default: default:

25
nx.json
View File

@ -1,26 +1,39 @@
{ {
"implicitDependencies": { "implicitDependencies": {
"angular.json": "*", "angular.json": "*",
"package.json": { "dependencies": "*", "devDependencies": "*" }, "package.json": {
"dependencies": "*",
"devDependencies": "*"
},
"tsconfig.base.json": "*", "tsconfig.base.json": "*",
".eslintrc.json": "*", ".eslintrc.json": "*",
"nx.json": "*" "nx.json": "*"
}, },
"affected": { "defaultBase": "origin/main" }, "affected": {
"defaultBase": "origin/main"
},
"npmScope": "ghostfolio", "npmScope": "ghostfolio",
"tasksRunnerOptions": { "tasksRunnerOptions": {
"default": { "default": {
"runner": "@nrwl/workspace/tasks-runners/default", "runner": "@nrwl/workspace/tasks-runners/default",
"options": { "cacheableOperations": ["build", "lint", "test", "e2e"] } "options": {
"cacheableOperations": ["build", "lint", "test", "e2e"]
}
} }
}, },
"projects": { "projects": {
"api": { "tags": [] }, "api": {
"client": { "tags": [] }, "tags": []
},
"client": {
"tags": []
},
"client-e2e": { "client-e2e": {
"tags": [], "tags": [],
"implicitDependencies": ["client"] "implicitDependencies": ["client"]
}, },
"common": { "tags": [] } "common": {
"tags": []
}
} }
} }

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