Compare commits
27 Commits
Author | SHA1 | Date | |
---|---|---|---|
e126f9ec54 | |||
09bbda3502 | |||
ee9a521813 | |||
169c151547 | |||
3a95ec0f81 | |||
ad00cd9d81 | |||
373a2015c0 | |||
66c955ad6c | |||
a2440fc067 | |||
3d7624d997 | |||
0264b592b9 | |||
198eaf57d3 | |||
6783ea2ebb | |||
a35701fe24 | |||
5db90f1787 | |||
81fe538484 | |||
51884913be | |||
8886082dfa | |||
3b12e5b85b | |||
6c1119caec | |||
698d5ec3b7 | |||
e87c942cb8 | |||
f7860a9799 | |||
c519eb0e99 | |||
8314b98f81 | |||
194cf1ddcc | |||
7da6478699 |
85
CHANGELOG.md
85
CHANGELOG.md
@ -5,6 +5,77 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.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
|
||||
@ -100,11 +171,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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
|
||||
|
||||
- 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
|
||||
|
||||
@ -236,7 +307,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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
|
||||
|
||||
## 0.92.0 - 25.04.2021
|
||||
@ -244,7 +315,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
@ -271,7 +342,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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
|
||||
|
||||
@ -302,7 +373,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### 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
|
||||
|
||||
@ -317,7 +388,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Changed the about page for the new license
|
||||
- Optimized the data management for historical data
|
||||
- Optimized the exchange rate service
|
||||
- Improved the user table of the admin control panel
|
||||
- Improved the users table of the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
|
@ -88,12 +88,14 @@ Please make sure you have completed the instructions from [_Setup_](#Setup)
|
||||
|
||||
### Start server
|
||||
|
||||
- Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_
|
||||
- Serve: Run `yarn start:server`
|
||||
<ol type="a">
|
||||
<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
|
||||
|
||||
- Run `yarn start:client`
|
||||
Run `yarn start:client`
|
||||
|
||||
## Testing
|
||||
|
||||
|
12
angular.json
12
angular.json
@ -86,7 +86,6 @@
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"aot": true,
|
||||
"assets": [
|
||||
"apps/client/src/assets",
|
||||
{
|
||||
@ -121,7 +120,13 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
"production": {
|
||||
@ -152,7 +157,8 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": ""
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
@ -26,6 +27,7 @@ import { InfoModule } from './info/info.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@ -34,6 +36,7 @@ import { UserModule } from './user/user.module';
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
AccountModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
@ -57,6 +60,7 @@ import { UserModule } from './user/user.module';
|
||||
rootPath: join(__dirname, '..', 'client'),
|
||||
exclude: ['/api*']
|
||||
}),
|
||||
SubscriptionModule,
|
||||
SymbolModule,
|
||||
UserModule
|
||||
],
|
||||
|
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('auth-device')
|
||||
export class AuthDeviceController {
|
||||
public constructor(
|
||||
private readonly authDeviceService: AuthDeviceService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.deleteAuthDevice
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
await this.authDeviceService.deleteAuthDevice({ id });
|
||||
}
|
||||
}
|
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface AuthDeviceDto {
|
||||
createdAt: string;
|
||||
id: string;
|
||||
}
|
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthDeviceController],
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
})
|
||||
],
|
||||
providers: [AuthDeviceService, ConfigurationService, PrismaService]
|
||||
})
|
||||
export class AuthDeviceModule {}
|
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthDevice, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthDeviceService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService
|
||||
) {}
|
||||
|
||||
public async authDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
): Promise<AuthDevice | null> {
|
||||
return this.prisma.authDevice.findUnique({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async authDevices(params: {
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.AuthDeviceWhereUniqueInput;
|
||||
where?: Prisma.AuthDeviceWhereInput;
|
||||
orderBy?: Prisma.AuthDeviceOrderByInput;
|
||||
}): Promise<AuthDevice[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prisma.authDevice.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
where,
|
||||
orderBy
|
||||
});
|
||||
}
|
||||
|
||||
public async createAuthDevice(
|
||||
data: Prisma.AuthDeviceCreateInput
|
||||
): Promise<AuthDevice> {
|
||||
return this.prisma.authDevice.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async updateAuthDevice(params: {
|
||||
data: Prisma.AuthDeviceUpdateInput;
|
||||
where: Prisma.AuthDeviceWhereUniqueInput;
|
||||
}): Promise<AuthDevice> {
|
||||
const { data, where } = params;
|
||||
|
||||
return this.prisma.authDevice.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAuthDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
): Promise<AuthDevice> {
|
||||
return this.prisma.authDevice.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
@ -1,9 +1,12 @@
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpException,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards
|
||||
@ -12,12 +15,17 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
AttestationCredentialJSON
|
||||
} from './interfaces/simplewebauthn';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
public constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly webAuthService: WebAuthService
|
||||
) {}
|
||||
|
||||
@Get('anonymous/:accessToken')
|
||||
@ -53,4 +61,44 @@ export class AuthController {
|
||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('webauthn/generate-attestation-options')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async generateAttestationOptions() {
|
||||
return this.webAuthService.generateAttestationOptions();
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-attestation')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async verifyAttestation(
|
||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||
) {
|
||||
return this.webAuthService.verifyAttestation(
|
||||
body.deviceName,
|
||||
body.credential
|
||||
);
|
||||
}
|
||||
|
||||
@Post('webauthn/generate-assertion-options')
|
||||
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
|
||||
return this.webAuthService.generateAssertionOptions(body.deviceId);
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-assertion')
|
||||
public async verifyAssertion(
|
||||
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
|
||||
) {
|
||||
try {
|
||||
const authToken = await this.webAuthService.verifyAssertion(
|
||||
body.deviceId,
|
||||
body.credential
|
||||
);
|
||||
return { authToken };
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -18,12 +20,14 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
})
|
||||
],
|
||||
providers: [
|
||||
AuthDeviceService,
|
||||
AuthService,
|
||||
ConfigurationService,
|
||||
GoogleStrategy,
|
||||
JwtStrategy,
|
||||
PrismaService,
|
||||
UserService
|
||||
UserService,
|
||||
WebAuthService
|
||||
]
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
@ -1,5 +1,10 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
||||
export interface AuthDeviceDialogParams {
|
||||
authDevice: AuthDeviceDto;
|
||||
}
|
||||
|
||||
export interface ValidateOAuthLoginParams {
|
||||
provider: Provider;
|
||||
thirdPartyId: string;
|
||||
|
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
@ -0,0 +1,226 @@
|
||||
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
|
||||
readonly authenticatorData: ArrayBuffer;
|
||||
readonly signature: ArrayBuffer;
|
||||
readonly userHandle: ArrayBuffer | null;
|
||||
}
|
||||
export interface AuthenticatorAttestationResponse
|
||||
extends AuthenticatorResponse {
|
||||
readonly attestationObject: ArrayBuffer;
|
||||
}
|
||||
export interface AuthenticationExtensionsClientInputs {
|
||||
appid?: string;
|
||||
appidExclude?: string;
|
||||
credProps?: boolean;
|
||||
uvm?: boolean;
|
||||
}
|
||||
export interface AuthenticationExtensionsClientOutputs {
|
||||
appid?: boolean;
|
||||
credProps?: CredentialPropertiesOutput;
|
||||
uvm?: UvmEntries;
|
||||
}
|
||||
export interface AuthenticatorSelectionCriteria {
|
||||
authenticatorAttachment?: AuthenticatorAttachment;
|
||||
requireResidentKey?: boolean;
|
||||
residentKey?: ResidentKeyRequirement;
|
||||
userVerification?: UserVerificationRequirement;
|
||||
}
|
||||
export interface PublicKeyCredential extends Credential {
|
||||
readonly rawId: ArrayBuffer;
|
||||
readonly response: AuthenticatorResponse;
|
||||
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
|
||||
}
|
||||
export interface PublicKeyCredentialCreationOptions {
|
||||
attestation?: AttestationConveyancePreference;
|
||||
authenticatorSelection?: AuthenticatorSelectionCriteria;
|
||||
challenge: BufferSource;
|
||||
excludeCredentials?: PublicKeyCredentialDescriptor[];
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
pubKeyCredParams: PublicKeyCredentialParameters[];
|
||||
rp: PublicKeyCredentialRpEntity;
|
||||
timeout?: number;
|
||||
user: PublicKeyCredentialUserEntity;
|
||||
}
|
||||
export interface PublicKeyCredentialDescriptor {
|
||||
id: BufferSource;
|
||||
transports?: AuthenticatorTransport[];
|
||||
type: PublicKeyCredentialType;
|
||||
}
|
||||
export interface PublicKeyCredentialParameters {
|
||||
alg: COSEAlgorithmIdentifier;
|
||||
type: PublicKeyCredentialType;
|
||||
}
|
||||
export interface PublicKeyCredentialRequestOptions {
|
||||
allowCredentials?: PublicKeyCredentialDescriptor[];
|
||||
challenge: BufferSource;
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
rpId?: string;
|
||||
timeout?: number;
|
||||
userVerification?: UserVerificationRequirement;
|
||||
}
|
||||
export interface PublicKeyCredentialUserEntity
|
||||
extends PublicKeyCredentialEntity {
|
||||
displayName: string;
|
||||
id: BufferSource;
|
||||
}
|
||||
export interface AuthenticatorResponse {
|
||||
readonly clientDataJSON: ArrayBuffer;
|
||||
}
|
||||
export interface CredentialPropertiesOutput {
|
||||
rk?: boolean;
|
||||
}
|
||||
export interface Credential {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
}
|
||||
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
|
||||
id?: string;
|
||||
}
|
||||
export interface PublicKeyCredentialEntity {
|
||||
name: string;
|
||||
}
|
||||
export declare type AttestationConveyancePreference =
|
||||
| 'direct'
|
||||
| 'enterprise'
|
||||
| 'indirect'
|
||||
| 'none';
|
||||
export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb';
|
||||
export declare type COSEAlgorithmIdentifier = number;
|
||||
export declare type UserVerificationRequirement =
|
||||
| 'discouraged'
|
||||
| 'preferred'
|
||||
| 'required';
|
||||
export declare type UvmEntries = UvmEntry[];
|
||||
export declare type AuthenticatorAttachment = 'cross-platform' | 'platform';
|
||||
export declare type ResidentKeyRequirement =
|
||||
| 'discouraged'
|
||||
| 'preferred'
|
||||
| 'required';
|
||||
export declare type BufferSource = ArrayBufferView | ArrayBuffer;
|
||||
export declare type PublicKeyCredentialType = 'public-key';
|
||||
export declare type UvmEntry = number[];
|
||||
|
||||
export interface PublicKeyCredentialCreationOptionsJSON
|
||||
extends Omit<
|
||||
PublicKeyCredentialCreationOptions,
|
||||
'challenge' | 'user' | 'excludeCredentials'
|
||||
> {
|
||||
user: PublicKeyCredentialUserEntityJSON;
|
||||
challenge: Base64URLString;
|
||||
excludeCredentials: PublicKeyCredentialDescriptorJSON[];
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
}
|
||||
/**
|
||||
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
|
||||
* (eventually) get passed into navigator.credentials.get(...) in the browser.
|
||||
*/
|
||||
export interface PublicKeyCredentialRequestOptionsJSON
|
||||
extends Omit<
|
||||
PublicKeyCredentialRequestOptions,
|
||||
'challenge' | 'allowCredentials'
|
||||
> {
|
||||
challenge: Base64URLString;
|
||||
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
|
||||
extensions?: AuthenticationExtensionsClientInputs;
|
||||
}
|
||||
export interface PublicKeyCredentialDescriptorJSON
|
||||
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
|
||||
id: Base64URLString;
|
||||
}
|
||||
export interface PublicKeyCredentialUserEntityJSON
|
||||
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
|
||||
id: string;
|
||||
}
|
||||
/**
|
||||
* The value returned from navigator.credentials.create()
|
||||
*/
|
||||
export interface AttestationCredential extends PublicKeyCredential {
|
||||
response: AuthenticatorAttestationResponseFuture;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AttestationCredentialJSON
|
||||
extends Omit<
|
||||
AttestationCredential,
|
||||
'response' | 'rawId' | 'getClientExtensionResults'
|
||||
> {
|
||||
rawId: Base64URLString;
|
||||
response: AuthenticatorAttestationResponseJSON;
|
||||
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||
transports?: AuthenticatorTransport[];
|
||||
}
|
||||
/**
|
||||
* The value returned from navigator.credentials.get()
|
||||
*/
|
||||
export interface AssertionCredential extends PublicKeyCredential {
|
||||
response: AuthenticatorAssertionResponse;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AssertionCredentialJSON
|
||||
extends Omit<
|
||||
AssertionCredential,
|
||||
'response' | 'rawId' | 'getClientExtensionResults'
|
||||
> {
|
||||
rawId: Base64URLString;
|
||||
response: AuthenticatorAssertionResponseJSON;
|
||||
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AuthenticatorAttestationResponseJSON
|
||||
extends Omit<
|
||||
AuthenticatorAttestationResponseFuture,
|
||||
'clientDataJSON' | 'attestationObject'
|
||||
> {
|
||||
clientDataJSON: Base64URLString;
|
||||
attestationObject: Base64URLString;
|
||||
}
|
||||
/**
|
||||
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that
|
||||
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||
*/
|
||||
export interface AuthenticatorAssertionResponseJSON
|
||||
extends Omit<
|
||||
AuthenticatorAssertionResponse,
|
||||
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
|
||||
> {
|
||||
authenticatorData: Base64URLString;
|
||||
clientDataJSON: Base64URLString;
|
||||
signature: Base64URLString;
|
||||
userHandle?: string;
|
||||
}
|
||||
/**
|
||||
* A WebAuthn-compatible device and the information needed to verify assertions by it
|
||||
*/
|
||||
export declare type AuthenticatorDevice = {
|
||||
credentialPublicKey: Buffer;
|
||||
credentialID: Buffer;
|
||||
counter: number;
|
||||
transports?: AuthenticatorTransport[];
|
||||
};
|
||||
/**
|
||||
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
|
||||
*/
|
||||
export declare type Base64URLString = string;
|
||||
/**
|
||||
* AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7).
|
||||
* Maintain an augmented version here so we can implement additional properties as the WebAuthn
|
||||
* spec evolves.
|
||||
*
|
||||
* See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse
|
||||
*
|
||||
* Properties marked optional are not supported in all browsers.
|
||||
*/
|
||||
export interface AuthenticatorAttestationResponseFuture
|
||||
extends AuthenticatorAttestationResponse {
|
||||
getTransports?: () => AuthenticatorTransport[];
|
||||
getAuthenticatorData?: () => ArrayBuffer;
|
||||
getPublicKey?: () => ArrayBuffer;
|
||||
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
|
||||
}
|
216
apps/api/src/app/auth/web-auth.service.ts
Normal file
216
apps/api/src/app/auth/web-auth.service.ts
Normal file
@ -0,0 +1,216 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
GenerateAssertionOptionsOpts,
|
||||
GenerateAttestationOptionsOpts,
|
||||
VerifiedAssertion,
|
||||
VerifiedAttestation,
|
||||
VerifyAssertionResponseOpts,
|
||||
VerifyAttestationResponseOpts,
|
||||
generateAssertionOptions,
|
||||
generateAttestationOptions,
|
||||
verifyAssertionResponse,
|
||||
verifyAttestationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
AttestationCredentialJSON
|
||||
} from './interfaces/simplewebauthn';
|
||||
|
||||
@Injectable()
|
||||
export class WebAuthService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly deviceService: AuthDeviceService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly userService: UserService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
get rpID() {
|
||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||
}
|
||||
|
||||
get expectedOrigin() {
|
||||
return this.configurationService.get('ROOT_URL');
|
||||
}
|
||||
|
||||
public async generateAttestationOptions() {
|
||||
const user = this.request.user;
|
||||
|
||||
const opts: GenerateAttestationOptionsOpts = {
|
||||
rpName: 'Ghostfolio',
|
||||
rpID: this.rpID,
|
||||
userID: user.id,
|
||||
userName: user.alias,
|
||||
timeout: 60000,
|
||||
attestationType: 'indirect',
|
||||
authenticatorSelection: {
|
||||
authenticatorAttachment: 'platform',
|
||||
requireResidentKey: false,
|
||||
userVerification: 'required'
|
||||
}
|
||||
};
|
||||
|
||||
const options = generateAttestationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
authChallenge: options.challenge
|
||||
},
|
||||
where: {
|
||||
id: user.id
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public async verifyAttestation(
|
||||
deviceName: string,
|
||||
credential: AttestationCredentialJSON
|
||||
): Promise<AuthDeviceDto> {
|
||||
const user = this.request.user;
|
||||
const expectedChallenge = user.authChallenge;
|
||||
|
||||
let verification: VerifiedAttestation;
|
||||
try {
|
||||
const opts: VerifyAttestationResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
};
|
||||
verification = await verifyAttestationResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new InternalServerErrorException(error.message);
|
||||
}
|
||||
|
||||
const { verified, attestationInfo } = verification;
|
||||
|
||||
const devices = await this.deviceService.authDevices({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (verified && attestationInfo) {
|
||||
const { credentialPublicKey, credentialID, counter } = attestationInfo;
|
||||
|
||||
let existingDevice = devices.find(
|
||||
(device) => device.credentialId === credentialID
|
||||
);
|
||||
|
||||
if (!existingDevice) {
|
||||
/**
|
||||
* Add the returned device to the user's list of devices
|
||||
*/
|
||||
existingDevice = await this.deviceService.createAuthDevice({
|
||||
credentialPublicKey,
|
||||
credentialId: credentialID,
|
||||
counter,
|
||||
User: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
createdAt: existingDevice.createdAt.toISOString(),
|
||||
id: existingDevice.id
|
||||
};
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException('An unknown error occurred');
|
||||
}
|
||||
|
||||
public async generateAssertionOptions(deviceId: string) {
|
||||
const device = await this.deviceService.authDevice({ id: deviceId });
|
||||
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const opts: GenerateAssertionOptionsOpts = {
|
||||
timeout: 60000,
|
||||
allowCredentials: [
|
||||
{
|
||||
id: device.credentialId,
|
||||
type: 'public-key',
|
||||
transports: ['internal']
|
||||
}
|
||||
],
|
||||
userVerification: 'preferred',
|
||||
rpID: this.rpID
|
||||
};
|
||||
|
||||
const options = generateAssertionOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
authChallenge: options.challenge
|
||||
},
|
||||
where: {
|
||||
id: device.userId
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
public async verifyAssertion(
|
||||
deviceId: string,
|
||||
credential: AssertionCredentialJSON
|
||||
) {
|
||||
const device = await this.deviceService.authDevice({ id: deviceId });
|
||||
|
||||
if (!device) {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const user = await this.userService.user({ id: device.userId });
|
||||
|
||||
let verification: VerifiedAssertion;
|
||||
try {
|
||||
const opts: VerifyAssertionResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID,
|
||||
authenticator: {
|
||||
credentialID: device.credentialId,
|
||||
credentialPublicKey: device.credentialPublicKey,
|
||||
counter: device.counter
|
||||
}
|
||||
};
|
||||
verification = verifyAssertionResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new InternalServerErrorException({ error: error.message });
|
||||
}
|
||||
|
||||
const { verified, assertionInfo } = verification;
|
||||
|
||||
if (verified) {
|
||||
device.counter = assertionInfo.newCounter;
|
||||
|
||||
await this.deviceService.updateAuthDevice({
|
||||
data: device,
|
||||
where: { id: device.id }
|
||||
});
|
||||
|
||||
return this.jwtService.sign({
|
||||
id: user.id
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error();
|
||||
}
|
||||
}
|
@ -1,10 +1,13 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Currency } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class InfoService {
|
||||
@ -28,6 +31,10 @@ export class InfoService {
|
||||
globalPermissions.push(permissions.enableSocialLogin);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
|
||||
globalPermissions.push(permissions.enableStatistics);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
globalPermissions.push(permissions.enableSubscription);
|
||||
}
|
||||
@ -37,10 +44,59 @@ export class InfoService {
|
||||
platforms,
|
||||
currencies: Object.values(Currency),
|
||||
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() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
@ -54,4 +110,36 @@ export class InfoService {
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
57
apps/api/src/app/subscription/subscription.controller.ts
Normal file
57
apps/api/src/app/subscription/subscription.controller.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
13
apps/api/src/app/subscription/subscription.module.ts
Normal file
13
apps/api/src/app/subscription/subscription.module.ts
Normal 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 {}
|
88
apps/api/src/app/subscription/subscription.service.ts
Normal file
88
apps/api/src/app/subscription/subscription.service.ts
Normal file
@ -0,0 +1,88 @@
|
||||
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
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
user_id: userId
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
|
||||
export interface UserSettingsParams {
|
||||
currency?: Currency;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}
|
@ -25,6 +25,7 @@ import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
||||
import { UserService } from './user.service';
|
||||
|
||||
@ -92,10 +93,20 @@ export class UserController {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSettings({
|
||||
const userSettings: UserSettingsParams = {
|
||||
currency: data.baseCurrency,
|
||||
userId: this.request.user.id,
|
||||
viewMode: data.viewMode
|
||||
});
|
||||
userId: this.request.user.id
|
||||
};
|
||||
|
||||
if (
|
||||
hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.updateViewMode
|
||||
)
|
||||
) {
|
||||
userSettings.viewMode = data.viewMode;
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSettings(userSettings);
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { locale } from '@ghostfolio/common/config';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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');
|
||||
|
||||
@ -24,7 +25,7 @@ export class UserService {
|
||||
Account,
|
||||
alias,
|
||||
id,
|
||||
role,
|
||||
permissions,
|
||||
Settings,
|
||||
subscription
|
||||
}: UserWithSettings): Promise<IUser> {
|
||||
@ -36,15 +37,10 @@ export class UserService {
|
||||
where: { GranteeUser: { id } }
|
||||
});
|
||||
|
||||
const currentPermissions = getPermissions(role);
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
currentPermissions.push(permissions.accessFearAndGreedIndex);
|
||||
}
|
||||
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
subscription,
|
||||
access: access.map((accessItem) => {
|
||||
return {
|
||||
@ -53,7 +49,6 @@ export class UserService {
|
||||
};
|
||||
}),
|
||||
accounts: Account,
|
||||
permissions: currentPermissions,
|
||||
settings: {
|
||||
locale,
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
@ -72,6 +67,14 @@ export class UserService {
|
||||
|
||||
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.currency) {
|
||||
// Set default currency if needed
|
||||
@ -106,6 +109,13 @@ export class UserService {
|
||||
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;
|
||||
@ -213,11 +223,7 @@ export class UserService {
|
||||
currency,
|
||||
userId,
|
||||
viewMode
|
||||
}: {
|
||||
currency?: Currency;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}) {
|
||||
}: UserSettingsParams) {
|
||||
await this.prisma.settings.upsert({
|
||||
create: {
|
||||
currency,
|
||||
|
@ -120,6 +120,7 @@ describe('Portfolio', () => {
|
||||
}
|
||||
],
|
||||
alias: 'Test',
|
||||
authChallenge: null,
|
||||
createdAt: new Date(),
|
||||
id: USER_ID,
|
||||
provider: null,
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
@ -210,6 +211,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
symbols.forEach((symbol) => {
|
||||
const accounts: PortfolioPosition['accounts'] = {};
|
||||
let countriesOfSymbol: Country[];
|
||||
let sectorsOfSymbol: Sector[];
|
||||
const [portfolioItem] = portfolioItems;
|
||||
|
||||
const ordersBySymbol = this.getOrders().filter((order) => {
|
||||
@ -264,6 +266,17 @@ export class Portfolio implements PortfolioInterface {
|
||||
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;
|
||||
@ -318,6 +331,7 @@ export class Portfolio implements PortfolioInterface {
|
||||
grossPerformancePercent: roundTo((now - before) / before, 4),
|
||||
investment: portfolioItem.positions[symbol].investment,
|
||||
quantity: portfolioItem.positions[symbol].quantity,
|
||||
sectors: sectorsOfSymbol,
|
||||
transactionCount: portfolioItem.positions[symbol].transactionCount,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
portfolioItem.positions[symbol].quantity * now,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
@ -17,6 +17,7 @@ export class ConfigurationService {
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
||||
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||
@ -26,7 +27,9 @@ export class ConfigurationService {
|
||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
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' })
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -12,9 +12,7 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
Industry,
|
||||
MarketState,
|
||||
Sector,
|
||||
Type
|
||||
} from '../../interfaces/interfaces';
|
||||
import {
|
||||
@ -70,16 +68,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
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;
|
||||
if (url) {
|
||||
response[symbol].url = url;
|
||||
@ -228,55 +216,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
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 {
|
||||
if (aString?.toLowerCase() === 'cryptocurrency') {
|
||||
return Type.Cryptocurrency;
|
||||
@ -291,6 +230,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
export const convertFromYahooSymbol = (aSymbol: string) => {
|
||||
let symbol = aSymbol.replace('-', '');
|
||||
const symbol = aSymbol.replace('-', '');
|
||||
return symbol.replace('=X', '');
|
||||
};
|
||||
|
@ -8,6 +8,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||
ENABLE_FEATURE_STATISTICS: boolean;
|
||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
@ -18,4 +19,6 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
REDIS_HOST: string;
|
||||
REDIS_PORT: number;
|
||||
ROOT_URL: string;
|
||||
STRIPE_SECRET_KEY: string;
|
||||
WEB_AUTH_RP_ID: string;
|
||||
}
|
||||
|
@ -3,29 +3,12 @@ import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
|
||||
|
||||
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 = {
|
||||
closed: 'closed',
|
||||
delayed: 'delayed',
|
||||
open: 'open'
|
||||
};
|
||||
|
||||
export const Sector = {
|
||||
Consumer: 'Consumer',
|
||||
Healthcare: 'Healthcare',
|
||||
Technology: 'Technology',
|
||||
Unknown: UNKNOWN_KEY
|
||||
};
|
||||
|
||||
export const Type = {
|
||||
Cryptocurrency: 'Cryptocurrency',
|
||||
ETF: 'ETF',
|
||||
@ -55,13 +38,11 @@ export interface IDataProviderResponse {
|
||||
currency: Currency;
|
||||
dataSource: DataSource;
|
||||
exchange?: string;
|
||||
industry?: Industry;
|
||||
marketChange?: number;
|
||||
marketChangePercent?: number;
|
||||
marketPrice: number;
|
||||
marketState: MarketState;
|
||||
name: string;
|
||||
sector?: Sector;
|
||||
type?: Type;
|
||||
url?: string;
|
||||
}
|
||||
@ -72,10 +53,6 @@ export interface IDataGatheringItem {
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
export type Industry = typeof Industry[keyof typeof Industry];
|
||||
|
||||
export type MarketState = typeof MarketState[keyof typeof MarketState];
|
||||
|
||||
export type Sector = typeof Sector[keyof typeof Sector];
|
||||
|
||||
export type Type = typeof Type[keyof typeof Type];
|
||||
|
@ -16,8 +16,8 @@ module.exports = {
|
||||
},
|
||||
coverageDirectory: '../../coverage/apps/client',
|
||||
snapshotSerializers: [
|
||||
'jest-preset-angular/build/AngularNoNgAttributesSnapshotSerializer.js',
|
||||
'jest-preset-angular/build/AngularSnapshotSerializer.js',
|
||||
'jest-preset-angular/build/HTMLCommentSerializer.js'
|
||||
'jest-preset-angular/build/serializers/no-ng-attributes',
|
||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||
'jest-preset-angular/build/serializers/html-comment'
|
||||
]
|
||||
};
|
||||
|
@ -92,6 +92,13 @@ const routes: Routes = [
|
||||
(m) => m.TransactionsPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'webauthn',
|
||||
loadChildren: () =>
|
||||
import('./pages/webauthn/webauthn-page.module').then(
|
||||
(m) => m.WebauthnPageModule
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'zen',
|
||||
loadChildren: () =>
|
||||
|
@ -15,7 +15,9 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MaterialCssVarsModule } from 'angular-material-css-vars';
|
||||
import { MarkdownModule } from 'ngx-markdown';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { NgxStripeModule } from 'ngx-stripe';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { CustomDateAdapter } from './adapter/custom-date-adapter';
|
||||
import { DateFormats } from './adapter/date-formats';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
@ -43,7 +45,8 @@ import { LanguageService } from './core/language.service';
|
||||
}),
|
||||
MatNativeDateModule,
|
||||
MatSnackBarModule,
|
||||
NgxSkeletonLoaderModule
|
||||
NgxSkeletonLoaderModule,
|
||||
NgxStripeModule.forRoot(environment.stripePublicKey)
|
||||
],
|
||||
providers: [
|
||||
authInterceptorProviders,
|
||||
|
@ -3,6 +3,11 @@
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.name }}
|
||||
<span
|
||||
*ngIf="element.isDefault"
|
||||
class="d-lg-inline-block d-none text-muted"
|
||||
>(Default)</span
|
||||
>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
@ -49,8 +54,10 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="transactions">
|
||||
<th *matHeaderCellDef i18n mat-header-cell>Transactions</th>
|
||||
<td *matCellDef="let element" mat-cell>
|
||||
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
|
||||
Transactions
|
||||
</th>
|
||||
<td *matCellDef="let element" class="text-right" mat-cell>
|
||||
{{ element.Order?.length }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
@ -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 { DataService } from '@ghostfolio/client/services/data.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 { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
@ -42,6 +46,7 @@ export class HeaderComponent implements OnChanges {
|
||||
private dialog: MatDialog,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
private tokenStorageService: TokenStorageService
|
||||
) {
|
||||
this.impersonationStorageService
|
||||
@ -87,7 +92,8 @@ export class HeaderComponent implements OnChanges {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
accessToken: '',
|
||||
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin
|
||||
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
|
||||
title: 'Sign in'
|
||||
},
|
||||
width: '30rem'
|
||||
});
|
||||
@ -112,7 +118,10 @@ export class HeaderComponent implements OnChanges {
|
||||
}
|
||||
|
||||
public setToken(aToken: string) {
|
||||
this.tokenStorageService.saveToken(aToken);
|
||||
this.tokenStorageService.saveToken(
|
||||
aToken,
|
||||
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
||||
);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
@ -1,5 +1,10 @@
|
||||
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({
|
||||
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'
|
||||
})
|
||||
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() {}
|
||||
|
||||
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
|
||||
this.settingsStorageService.setSetting(
|
||||
STAY_SIGNED_IN,
|
||||
aValue.checked?.toString()
|
||||
);
|
||||
}
|
||||
|
||||
public onClose() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,9 @@
|
||||
<h1 mat-dialog-title i18n>Sign in</h1>
|
||||
<gf-dialog-header
|
||||
mat-dialog-title
|
||||
[title]="data.title"
|
||||
(closeButtonClicked)="onClose()"
|
||||
></gf-dialog-header>
|
||||
|
||||
<div mat-dialog-content>
|
||||
<div>
|
||||
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
|
||||
@ -21,15 +26,21 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="float-right" mat-dialog-actions>
|
||||
<button i18n mat-flat-button [mat-dialog-close]="undefined">Cancel</button>
|
||||
<button
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!data.accessToken"
|
||||
[mat-dialog-close]="data"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<div mat-dialog-actions>
|
||||
<div class="flex-grow-1">
|
||||
<mat-checkbox i18n (change)="onChangeStaySignedIn($event)"
|
||||
>Stay signed in</mat-checkbox
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[disabled]="!data.accessToken"
|
||||
[mat-dialog-close]="data"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,10 +3,12 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
|
||||
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
@ -15,7 +17,9 @@ import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.com
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfDialogHeaderModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
|
@ -1,5 +1,15 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
textarea.mat-input-element.cdk-textarea-autosize {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.mat-checkbox {
|
||||
::ng-deep {
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +0,0 @@
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
[theme]="{
|
||||
height: '30rem',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
<canvas
|
||||
#timelineCanvas
|
||||
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
|
||||
></canvas>
|
@ -1,3 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { PortfolioPositionsChartComponent } from './portfolio-positions-chart.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [PortfolioPositionsChartComponent],
|
||||
exports: [PortfolioPositionsChartComponent],
|
||||
imports: [CommonModule, NgxSkeletonLoaderModule],
|
||||
providers: []
|
||||
})
|
||||
export class PortfolioPositionsChartModule {}
|
@ -29,6 +29,7 @@ export class PortfolioProportionChartComponent
|
||||
@Input() isInPercent: boolean;
|
||||
@Input() key: string;
|
||||
@Input() locale: string;
|
||||
@Input() maxItems?: number;
|
||||
@Input() positions: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
|
||||
};
|
||||
@ -90,12 +91,40 @@ export class PortfolioProportionChartComponent
|
||||
}
|
||||
});
|
||||
|
||||
const chartDataSorted = Object.entries(chartData)
|
||||
let chartDataSorted = Object.entries(chartData)
|
||||
.sort((a, b) => {
|
||||
return a[1].value - b[1].value;
|
||||
})
|
||||
.reverse();
|
||||
|
||||
if (this.maxItems && chartDataSorted.length > this.maxItems) {
|
||||
// Add surplus items to unknown group
|
||||
const rest = chartDataSorted.splice(
|
||||
this.maxItems,
|
||||
chartDataSorted.length - 1
|
||||
);
|
||||
|
||||
let unknownItem = chartDataSorted.find((charDataItem) => {
|
||||
return charDataItem[0] === UNKNOWN_KEY;
|
||||
});
|
||||
|
||||
if (!unknownItem) {
|
||||
const index = chartDataSorted.push([UNKNOWN_KEY, { value: 0 }]);
|
||||
unknownItem = chartDataSorted[index];
|
||||
}
|
||||
|
||||
rest.forEach((restItem) => {
|
||||
unknownItem[1] = { value: unknownItem[1].value + restItem[1].value };
|
||||
});
|
||||
|
||||
// Sort data again
|
||||
chartDataSorted = chartDataSorted
|
||||
.sort((a, b) => {
|
||||
return a[1].value - b[1].value;
|
||||
})
|
||||
.reverse();
|
||||
}
|
||||
|
||||
chartDataSorted.forEach(([symbol, item], index) => {
|
||||
if (this.colorMap[symbol]) {
|
||||
// Reuse color
|
||||
|
@ -40,6 +40,16 @@
|
||||
mat-table
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="count">
|
||||
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>#</th>
|
||||
<td
|
||||
*matCellDef="let element; let i = index"
|
||||
class="px-1 text-right"
|
||||
mat-cell
|
||||
>
|
||||
{{ dataSource.data.length - i }}
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="date">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
|
@ -133,6 +133,7 @@ export class TransactionsTableComponent
|
||||
|
||||
public ngOnChanges() {
|
||||
this.displayedColumns = [
|
||||
'count',
|
||||
'date',
|
||||
'type',
|
||||
'symbol',
|
||||
|
@ -40,7 +40,10 @@ export class AuthGuard implements CanActivate {
|
||||
.get()
|
||||
.pipe(
|
||||
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);
|
||||
return EMPTY;
|
||||
} else if (state.url !== '/start') {
|
||||
|
@ -2,12 +2,10 @@ import {
|
||||
HTTP_INTERCEPTORS,
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpResponse
|
||||
} from '@angular/common/http';
|
||||
import {
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest
|
||||
HttpRequest,
|
||||
HttpResponse
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
@ -16,6 +14,7 @@ import {
|
||||
TextOnlySnackBar
|
||||
} from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { catchError, tap } from 'rxjs/operators';
|
||||
@ -29,7 +28,8 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
||||
public constructor(
|
||||
private router: Router,
|
||||
private tokenStorageService: TokenStorageService,
|
||||
private snackBar: MatSnackBar
|
||||
private snackBar: MatSnackBar,
|
||||
private webAuthnService: WebAuthnService
|
||||
) {}
|
||||
|
||||
public intercept(
|
||||
@ -78,7 +78,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
||||
});
|
||||
}
|
||||
} else if (error.status === StatusCodes.UNAUTHORIZED) {
|
||||
this.tokenStorageService.signOut();
|
||||
if (this.webAuthnService.isEnabled()) {
|
||||
this.router.navigate(['/webauthn']);
|
||||
} else {
|
||||
this.tokenStorageService.signOut();
|
||||
}
|
||||
}
|
||||
|
||||
return throwError('');
|
||||
|
@ -1,7 +1,10 @@
|
||||
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 { baseCurrency } from '@ghostfolio/common/config';
|
||||
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 { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -14,8 +17,10 @@ import { environment } from '../../../environments/environment';
|
||||
})
|
||||
export class AboutPageComponent implements OnInit {
|
||||
public baseCurrency = baseCurrency;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public isLoggedIn: boolean;
|
||||
public lastPublish = environment.lastPublish;
|
||||
public statistics: Statistics;
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
@ -26,6 +31,7 @@ export class AboutPageComponent implements OnInit {
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
@ -33,6 +39,19 @@ export class AboutPageComponent implements OnInit {
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.dataService
|
||||
.fetchInfo()
|
||||
.subscribe(({ globalPermissions, statistics }) => {
|
||||
this.hasPermissionForStatistics = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableStatistics
|
||||
);
|
||||
|
||||
this.statistics = statistics;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<p>
|
||||
<strong>Ghostfolio</strong> is open source software which empowers
|
||||
@ -17,21 +17,13 @@
|
||||
</p>
|
||||
<p>
|
||||
If you encounter a bug or would like to suggest an improvement or a
|
||||
new feature, please open an issue at
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>, tweet
|
||||
to <a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or
|
||||
send an e-mail to
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
new feature, please tweet to
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>, send an
|
||||
e-mail to <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or open
|
||||
an issue at
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||
</p>
|
||||
<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
|
||||
class="mx-2"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
@ -48,6 +40,14 @@
|
||||
>
|
||||
<ion-icon name="mail" size="large"></ion-icon>
|
||||
</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>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div
|
||||
@ -60,10 +60,44 @@
|
||||
</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="col">
|
||||
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
||||
<mat-card class="changelog mb-3">
|
||||
<mat-card class="changelog">
|
||||
<mat-card-content>
|
||||
<markdown [src]="'CHANGELOG.md'"></markdown>
|
||||
</mat-card-content>
|
||||
@ -74,7 +108,7 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>License</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card>
|
||||
<mat-card-content>
|
||||
<markdown [src]="'LICENSE'"></markdown>
|
||||
</mat-card-content>
|
||||
|
@ -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 { 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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { StripeService } from 'ngx-stripe';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-account-page',
|
||||
@ -14,11 +26,20 @@ import { takeUntil } from 'rxjs/operators';
|
||||
styleUrls: ['./account-page.scss']
|
||||
})
|
||||
export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
@ViewChild('toggleSignInWithFingerprintEnabledElement')
|
||||
signInWithFingerprintElement: MatSlideToggle;
|
||||
|
||||
public accesses: Access[];
|
||||
public baseCurrency: Currency;
|
||||
public baseCurrency = baseCurrency;
|
||||
public coupon: number;
|
||||
public couponId: string;
|
||||
public currencies: Currency[] = [];
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public hasPermissionForSubscription;
|
||||
public hasPermissionToUpdateViewMode: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public price: number;
|
||||
public priceId: string;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -29,13 +50,27 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
private stripeService: StripeService,
|
||||
private userService: UserService,
|
||||
public webAuthnService: WebAuthnService
|
||||
) {
|
||||
this.dataService
|
||||
.fetchInfo()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ currencies }) => {
|
||||
.subscribe(({ currencies, globalPermissions, subscriptions }) => {
|
||||
this.coupon = subscriptions?.[0]?.coupon;
|
||||
this.couponId = subscriptions?.[0]?.couponId;
|
||||
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
|
||||
@ -49,6 +84,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
permissions.updateUserSettings
|
||||
);
|
||||
|
||||
this.hasPermissionToUpdateViewMode = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.updateViewMode
|
||||
);
|
||||
|
||||
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() {
|
||||
this.unsubscribeSubject.next();
|
||||
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() {
|
||||
this.dataService
|
||||
.fetchAccesses()
|
||||
@ -96,6 +199,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
.subscribe((response) => {
|
||||
this.accesses = response;
|
||||
|
||||
if (this.signInWithFingerprintElement) {
|
||||
this.signInWithFingerprintElement.checked =
|
||||
this.webAuthnService.isEnabled() ?? false;
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
@ -1,10 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3">
|
||||
<ng-container *ngIf="user?.alias">{{ user.alias }}</ng-container>
|
||||
<ng-container *ngIf="!user?.alias" i18n>Account</ng-container>
|
||||
</h3>
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Account</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="user?.settings" class="mb-5 row">
|
||||
@ -25,10 +22,26 @@
|
||||
Valid until {{ user.subscription.expiresAt | date:
|
||||
defaultDateFormat }}
|
||||
</div>
|
||||
<div *ngIf="!user.subscription.expiresAt">
|
||||
<button color="primary" disabled i18n mat-flat-button>
|
||||
<div
|
||||
*ngIf="hasPermissionForSubscription && !user.subscription.expiresAt"
|
||||
>
|
||||
<button
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
(click)="onCheckout(priceId)"
|
||||
>
|
||||
Upgrade
|
||||
</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>
|
||||
@ -51,21 +64,39 @@
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>View Mode</mat-label>
|
||||
<mat-select
|
||||
name="viewMode"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
[value]="user.settings.viewMode"
|
||||
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
|
||||
>
|
||||
<mat-option value="DEFAULT">Default</mat-option>
|
||||
<mat-option value="ZEN">Zen</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<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-select
|
||||
name="viewMode"
|
||||
[disabled]="!hasPermissionToUpdateViewMode"
|
||||
[value]="user.settings.viewMode"
|
||||
(selectionChange)="onChangeUserSettings('viewMode', $event.value)"
|
||||
>
|
||||
<mat-option value="DEFAULT">Default</mat-option>
|
||||
<mat-option value="ZEN">Zen</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<ion-icon
|
||||
*ngIf="!hasPermissionToUpdateViewMode"
|
||||
class="h5 mb-0 mx-3 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</form>
|
||||
</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>
|
||||
</div>
|
||||
|
@ -3,8 +3,11 @@ import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
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 { AccountPageRoutingModule } from './account-page-routing.module';
|
||||
@ -20,8 +23,11 @@ import { AccountPageComponent } from './account-page.component';
|
||||
GfPortfolioAccessTableModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatSlideToggleModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: []
|
||||
|
@ -6,7 +6,7 @@ import { GfAccountsTableModule } from '@ghostfolio/client/components/accounts-ta
|
||||
|
||||
import { AccountsPageRoutingModule } from './accounts-page-routing.module';
|
||||
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({
|
||||
declarations: [AccountsPageComponent],
|
||||
@ -14,8 +14,8 @@ import { CreateOrUpdateAccountDialogModule } from './create-or-update-account-di
|
||||
imports: [
|
||||
AccountsPageRoutingModule,
|
||||
CommonModule,
|
||||
CreateOrUpdateAccountDialogModule,
|
||||
GfAccountsTableModule,
|
||||
GfCreateOrUpdateAccountDialogModule,
|
||||
MatButtonModule,
|
||||
RouterModule
|
||||
],
|
||||
|
@ -1,6 +1,6 @@
|
||||
<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" mat-dialog-title i18n>Add account</h1>
|
||||
<h1 *ngIf="data.account.id" i18n mat-dialog-title>Update account</h1>
|
||||
<h1 *ngIf="!data.account.id" i18n mat-dialog-title>Add account</h1>
|
||||
<div class="flex-grow-1" mat-dialog-content>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
|
@ -24,4 +24,4 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
|
||||
],
|
||||
providers: []
|
||||
})
|
||||
export class CreateOrUpdateAccountDialogModule {}
|
||||
export class GfCreateOrUpdateAccountDialogModule {}
|
||||
|
@ -5,7 +5,12 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
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 { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -80,8 +85,8 @@ export class AdminPageComponent implements OnInit {
|
||||
addSuffix: true
|
||||
});
|
||||
|
||||
return distanceString === 'in 0 seconds' ||
|
||||
distanceString === '0 seconds ago'
|
||||
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
|
||||
60
|
||||
? 'just now'
|
||||
: distanceString;
|
||||
}
|
||||
|
@ -73,18 +73,18 @@
|
||||
<table class="gf-table">
|
||||
<thead>
|
||||
<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 text-center" i18n>
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||
Registration Date
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
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';
|
||||
|
||||
@Component({
|
||||
@ -14,6 +18,7 @@ export class AuthPageComponent implements OnInit {
|
||||
public constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
private tokenStorageService: TokenStorageService
|
||||
) {}
|
||||
|
||||
@ -23,7 +28,10 @@ export class AuthPageComponent implements OnInit {
|
||||
public ngOnInit() {
|
||||
this.route.params.subscribe((params) => {
|
||||
const jwt = params['jwt'];
|
||||
this.tokenStorageService.saveToken(jwt);
|
||||
this.tokenStorageService.saveToken(
|
||||
jwt,
|
||||
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
||||
);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
});
|
||||
|
@ -3,6 +3,7 @@ import { Router } from '@angular/router';
|
||||
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||
import { format } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@ -255,7 +256,7 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public setToken(aToken: string) {
|
||||
this.tokenStorageService.saveToken(aToken);
|
||||
this.tokenStorageService.saveToken(aToken, true);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
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 { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
@ -12,7 +13,9 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class PricingPageComponent implements OnInit {
|
||||
public baseCurrency = baseCurrency;
|
||||
public coupon: number;
|
||||
public isLoggedIn: boolean;
|
||||
public price: number;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -22,8 +25,19 @@ export class PricingPageComponent implements OnInit {
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
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
|
||||
|
@ -176,11 +176,17 @@
|
||||
</ul>
|
||||
</div>
|
||||
<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"
|
||||
>{{ user?.settings.baseCurrency || baseCurrency }}
|
||||
<strong>0.00</strong>
|
||||
<del class="ml-1 text-muted">3.99</del> / Month</span
|
||||
>{{ baseCurrency }}
|
||||
<ng-container *ngIf="coupon"
|
||||
><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>
|
||||
</mat-card>
|
||||
@ -188,6 +194,13 @@
|
||||
</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 class="col mt-3 text-center">
|
||||
<a color="primary" i18n mat-flat-button [routerLink]="['/register']">
|
||||
|
@ -78,19 +78,13 @@ export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
dialogRef.afterClosed().subscribe((data) => {
|
||||
if (data?.authToken) {
|
||||
this.tokenStorageService.saveToken(authToken);
|
||||
this.tokenStorageService.saveToken(authToken, true);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public setToken(aToken: string) {
|
||||
this.tokenStorageService.saveToken(aToken);
|
||||
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
|
||||
Create your Ghostfolio account
|
||||
Create your Account
|
||||
</h3>
|
||||
<mat-card class="mb-4">
|
||||
<mat-card-content class="text-center">
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
PortfolioPosition,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -39,6 +40,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public portfolioPositions: { [symbol: string]: PortfolioPosition };
|
||||
public positions: { [symbol: string]: any };
|
||||
public positionsArray: PortfolioPosition[];
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -118,13 +122,17 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
this.positions = {};
|
||||
this.positionsArray = [];
|
||||
this.sectors = {
|
||||
[UNKNOWN_KEY]: {
|
||||
name: UNKNOWN_KEY,
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
|
||||
for (const [symbol, position] of Object.entries(aPortfolioPositions)) {
|
||||
this.positions[symbol] = {
|
||||
currency: position.currency,
|
||||
exchange: position.exchange,
|
||||
industry: position.industry,
|
||||
sector: position.sector,
|
||||
type: position.type,
|
||||
value:
|
||||
aPeriod === 'original'
|
||||
@ -188,6 +196,30 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -58,50 +58,6 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</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="sector"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>By Industry</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="industry"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
@ -146,6 +102,29 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</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">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
@ -185,32 +164,13 @@
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
[maxItems]="10"
|
||||
[positions]="countries"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-block d-sm-none row">
|
||||
<div class="col-lg">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="d-flex align-items-center justify-content-center">
|
||||
<div class="mr-2">
|
||||
<ion-icon
|
||||
name="information-circle-outline"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row world-map-chart">
|
||||
<div class="col-lg">
|
||||
<mat-card class="mb-3">
|
||||
@ -241,9 +201,6 @@
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<!--<gf-positions-chart
|
||||
[portfolioItems]="portfolioItems"
|
||||
></gf-positions-chart>-->
|
||||
<gf-investment-chart
|
||||
[portfolioItems]="portfolioItems"
|
||||
></gf-investment-chart>
|
||||
|
@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
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 { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
@ -22,7 +21,6 @@ import { AnalysisPageComponent } from './analysis-page.component';
|
||||
GfToggleModule,
|
||||
GfWorldMapChartModule,
|
||||
MatCardModule,
|
||||
PortfolioPositionsChartModule,
|
||||
PortfolioProportionChartModule
|
||||
],
|
||||
providers: [],
|
||||
|
@ -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 {}
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
36
apps/client/src/app/pages/webauthn/webauthn-page.html
Normal file
36
apps/client/src/app/pages/webauthn/webauthn-page.html
Normal 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>
|
22
apps/client/src/app/pages/webauthn/webauthn-page.module.ts
Normal file
22
apps/client/src/app/pages/webauthn/webauthn-page.module.ts
Normal 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 {}
|
@ -43,6 +43,19 @@ export class DataService {
|
||||
private settingsStorageService: SettingsStorageService
|
||||
) {}
|
||||
|
||||
public createCheckoutSession({
|
||||
couponId,
|
||||
priceId
|
||||
}: {
|
||||
couponId?: string;
|
||||
priceId: string;
|
||||
}) {
|
||||
return this.http.post('/api/subscription/stripe/checkout-session', {
|
||||
couponId,
|
||||
priceId
|
||||
});
|
||||
}
|
||||
|
||||
public fetchAccounts() {
|
||||
return this.http.get<AccountModel[]>('/api/account');
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
export const RANGE = 'range';
|
||||
export const STAY_SIGNED_IN = 'staySignedIn';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -15,4 +16,8 @@ export class SettingsStorageService {
|
||||
public setSetting(aKey: string, aValue: string) {
|
||||
window.localStorage.setItem(aKey, aValue);
|
||||
}
|
||||
|
||||
public removeSetting(aKey: string): void {
|
||||
return window.localStorage.removeItem(aKey);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||
|
||||
import { UserService } from './user/user.service';
|
||||
|
||||
@ -8,21 +9,34 @@ const TOKEN_KEY = 'auth-token';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class TokenStorageService {
|
||||
public constructor(private userService: UserService) {}
|
||||
public constructor(
|
||||
private userService: UserService,
|
||||
private webAuthnService: WebAuthnService
|
||||
) {}
|
||||
|
||||
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 {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
window.localStorage.setItem(TOKEN_KEY, token);
|
||||
public saveToken(token: string, staySignedIn = false): void {
|
||||
if (staySignedIn) {
|
||||
window.localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
window.sessionStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
public signOut(): void {
|
||||
const utmSource = window.localStorage.getItem('utm_source');
|
||||
|
||||
if (this.webAuthnService.isEnabled()) {
|
||||
this.webAuthnService.deregister().subscribe();
|
||||
}
|
||||
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
|
||||
this.userService.remove();
|
||||
|
||||
|
104
apps/client/src/app/services/web-authn.service.ts
Normal file
104
apps/client/src/app/services/web-authn.service.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
3
apps/client/src/app/util/rxjs.util.ts
Normal file
3
apps/client/src/app/util/rxjs.util.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function isNonNull<T>(value: T): value is NonNullable<T> {
|
||||
return value != null;
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
export const environment = {
|
||||
lastPublish: '{BUILD_TIMESTAMP}',
|
||||
production: true,
|
||||
stripePublicKey: '{STRIPE_PUBLIC_KEY}',
|
||||
version: `v${require('../../../../package.json').version}`
|
||||
};
|
||||
|
@ -5,6 +5,7 @@
|
||||
export const environment = {
|
||||
lastPublish: null,
|
||||
production: false,
|
||||
stripePublicKey: '',
|
||||
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
|
||||
* 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.
|
||||
|
@ -55,7 +55,7 @@
|
||||
/***************************************************************************************************
|
||||
* 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
|
||||
|
@ -1 +1 @@
|
||||
import 'jest-preset-angular';
|
||||
import 'jest-preset-angular/setup-jest';
|
||||
|
@ -2,9 +2,7 @@ module.exports = {
|
||||
displayName: 'common',
|
||||
preset: '../../jest.preset.js',
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsConfig: '<rootDir>/tsconfig.spec.json'
|
||||
}
|
||||
'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' }
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.[tj]sx?$': 'ts-jest'
|
||||
|
@ -2,7 +2,7 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
|
||||
import { Currency } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export const baseCurrency = Currency.CHF;
|
||||
export const baseCurrency = Currency.USD;
|
||||
|
||||
export const benchmarks: Partial<IDataGatheringItem>[] = [
|
||||
{ dataSource: DataSource.YAHOO, symbol: 'VOO' }
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { Statistics } from './statistics.interface';
|
||||
import { Subscription } from './subscription.interface';
|
||||
|
||||
export interface InfoItem {
|
||||
currencies: Currency[];
|
||||
demoAuthToken: string;
|
||||
@ -10,4 +13,6 @@ export interface InfoItem {
|
||||
type: string;
|
||||
};
|
||||
platforms: { id: string; name: string }[];
|
||||
statistics: Statistics;
|
||||
subscriptions: Subscription[];
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { Country } from './country.interface';
|
||||
import { Sector } from './sector.interface';
|
||||
|
||||
export interface PortfolioPosition {
|
||||
accounts: {
|
||||
@ -14,7 +15,6 @@ export interface PortfolioPosition {
|
||||
exchange?: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
industry?: string;
|
||||
investment: number;
|
||||
marketChange?: number;
|
||||
marketChangePercent?: number;
|
||||
@ -22,7 +22,7 @@ export interface PortfolioPosition {
|
||||
marketState: MarketState;
|
||||
name: string;
|
||||
quantity: number;
|
||||
sector?: string;
|
||||
sectors: Sector[];
|
||||
transactionCount: number;
|
||||
symbol: string;
|
||||
type?: string;
|
||||
|
4
libs/common/src/lib/interfaces/sector.interface.ts
Normal file
4
libs/common/src/lib/interfaces/sector.interface.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Sector {
|
||||
name: string;
|
||||
weight: number;
|
||||
}
|
5
libs/common/src/lib/interfaces/statistics.interface.ts
Normal file
5
libs/common/src/lib/interfaces/statistics.interface.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export interface Statistics {
|
||||
activeUsers1d: number;
|
||||
activeUsers30d: number;
|
||||
gitHubStargazers: number;
|
||||
}
|
6
libs/common/src/lib/interfaces/subscription.interface.ts
Normal file
6
libs/common/src/lib/interfaces/subscription.interface.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface Subscription {
|
||||
coupon?: number;
|
||||
couponId?: string;
|
||||
price: number;
|
||||
priceId: string;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
|
||||
export interface UserSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency?: Currency;
|
||||
locale: string;
|
||||
viewMode: ViewMode;
|
||||
viewMode?: ViewMode;
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { Account, Settings, User } from '@prisma/client';
|
||||
|
||||
export type UserWithSettings = User & {
|
||||
Account: Account[];
|
||||
permissions?: string[];
|
||||
Settings: Settings;
|
||||
subscription?: {
|
||||
expiresAt?: Date;
|
||||
|
@ -11,14 +11,18 @@ export const permissions = {
|
||||
createOrder: 'createOrder',
|
||||
createUserAccount: 'createUserAccount',
|
||||
deleteAccount: 'deleteAcccount',
|
||||
deleteAuthDevice: 'deleteAuthDevice',
|
||||
deleteOrder: 'deleteOrder',
|
||||
deleteUser: 'deleteUser',
|
||||
enableSocialLogin: 'enableSocialLogin',
|
||||
enableStatistics: 'enableStatistics',
|
||||
enableSubscription: 'enableSubscription',
|
||||
readForeignPortfolio: 'readForeignPortfolio',
|
||||
updateAccount: 'updateAccount',
|
||||
updateAuthDevice: 'updateAuthDevice',
|
||||
updateOrder: 'updateOrder',
|
||||
updateUserSettings: 'updateUserSettings'
|
||||
updateUserSettings: 'updateUserSettings',
|
||||
updateViewMode: 'updateViewMode'
|
||||
};
|
||||
|
||||
export function hasPermission(
|
||||
@ -36,12 +40,15 @@ export function getPermissions(aRole: Role): string[] {
|
||||
permissions.createAccount,
|
||||
permissions.createOrder,
|
||||
permissions.deleteAccount,
|
||||
permissions.deleteAuthDevice,
|
||||
permissions.deleteOrder,
|
||||
permissions.deleteUser,
|
||||
permissions.readForeignPortfolio,
|
||||
permissions.updateAccount,
|
||||
permissions.updateAuthDevice,
|
||||
permissions.updateOrder,
|
||||
permissions.updateUserSettings
|
||||
permissions.updateUserSettings,
|
||||
permissions.updateViewMode
|
||||
];
|
||||
|
||||
case 'DEMO':
|
||||
@ -52,10 +59,13 @@ export function getPermissions(aRole: Role): string[] {
|
||||
permissions.createAccount,
|
||||
permissions.createOrder,
|
||||
permissions.deleteAccount,
|
||||
permissions.deleteAuthDevice,
|
||||
permissions.deleteOrder,
|
||||
permissions.updateAccount,
|
||||
permissions.updateAuthDevice,
|
||||
permissions.updateOrder,
|
||||
permissions.updateUserSettings
|
||||
permissions.updateUserSettings,
|
||||
permissions.updateViewMode
|
||||
];
|
||||
|
||||
default:
|
||||
|
25
nx.json
25
nx.json
@ -1,26 +1,39 @@
|
||||
{
|
||||
"implicitDependencies": {
|
||||
"angular.json": "*",
|
||||
"package.json": { "dependencies": "*", "devDependencies": "*" },
|
||||
"package.json": {
|
||||
"dependencies": "*",
|
||||
"devDependencies": "*"
|
||||
},
|
||||
"tsconfig.base.json": "*",
|
||||
".eslintrc.json": "*",
|
||||
"nx.json": "*"
|
||||
},
|
||||
"affected": { "defaultBase": "origin/main" },
|
||||
"affected": {
|
||||
"defaultBase": "origin/main"
|
||||
},
|
||||
"npmScope": "ghostfolio",
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
"runner": "@nrwl/workspace/tasks-runners/default",
|
||||
"options": { "cacheableOperations": ["build", "lint", "test", "e2e"] }
|
||||
"options": {
|
||||
"cacheableOperations": ["build", "lint", "test", "e2e"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"api": { "tags": [] },
|
||||
"client": { "tags": [] },
|
||||
"api": {
|
||||
"tags": []
|
||||
},
|
||||
"client": {
|
||||
"tags": []
|
||||
},
|
||||
"client-e2e": {
|
||||
"tags": [],
|
||||
"implicitDependencies": ["client"]
|
||||
},
|
||||
"common": { "tags": [] }
|
||||
"common": {
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
88
package.json
88
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.14.0",
|
||||
"version": "1.21.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -13,7 +13,7 @@
|
||||
"affected:lint": "nx affected:lint",
|
||||
"affected:test": "nx affected:test",
|
||||
"angular": "node --max_old_space_size=32768 ./node_modules/@angular/cli/bin/ng",
|
||||
"build:all": "ng build --prod api && ng build --prod client && yarn replace-placeholders-in-build",
|
||||
"build:all": "ng build --configuration production api && ng build --configuration production client && yarn replace-placeholders-in-build",
|
||||
"clean": "rimraf dist",
|
||||
"database:format-schema": "prisma format",
|
||||
"database:generate-typings": "prisma generate",
|
||||
@ -45,16 +45,16 @@
|
||||
"workspace-generator": "nx workspace-generator"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "11.2.4",
|
||||
"@angular/animations": "12.0.4",
|
||||
"@angular/cdk": "11.0.4",
|
||||
"@angular/common": "11.2.4",
|
||||
"@angular/compiler": "11.2.4",
|
||||
"@angular/core": "11.2.4",
|
||||
"@angular/forms": "11.2.4",
|
||||
"@angular/common": "12.0.4",
|
||||
"@angular/compiler": "12.0.4",
|
||||
"@angular/core": "12.0.4",
|
||||
"@angular/forms": "12.0.4",
|
||||
"@angular/material": "11.0.4",
|
||||
"@angular/platform-browser": "11.2.4",
|
||||
"@angular/platform-browser-dynamic": "11.2.4",
|
||||
"@angular/router": "11.2.4",
|
||||
"@angular/platform-browser": "12.0.4",
|
||||
"@angular/platform-browser-dynamic": "12.0.4",
|
||||
"@angular/router": "12.0.4",
|
||||
"@codewithdan/observable-store": "2.2.11",
|
||||
"@nestjs/common": "7.6.5",
|
||||
"@nestjs/config": "0.6.1",
|
||||
@ -64,32 +64,36 @@
|
||||
"@nestjs/platform-express": "7.6.5",
|
||||
"@nestjs/schedule": "0.4.1",
|
||||
"@nestjs/serve-static": "2.1.4",
|
||||
"@nrwl/angular": "12.0.0",
|
||||
"@nrwl/angular": "12.3.6",
|
||||
"@prisma/client": "2.24.1",
|
||||
"@simplewebauthn/browser": "3.0.0",
|
||||
"@simplewebauthn/server": "3.0.0",
|
||||
"@simplewebauthn/typescript-types": "3.0.0",
|
||||
"@stripe/stripe-js": "1.15.0",
|
||||
"@types/lodash": "4.14.168",
|
||||
"alphavantage": "2.2.0",
|
||||
"angular-material-css-vars": "1.1.2",
|
||||
"angular-material-css-vars": "1.2.0",
|
||||
"bent": "7.3.12",
|
||||
"bootstrap": "4.6.0",
|
||||
"cache-manager": "3.4.3",
|
||||
"cache-manager-redis-store": "2.0.0",
|
||||
"chart.js": "3.2.1",
|
||||
"chartjs-adapter-date-fns": "1.1.0-beta.1",
|
||||
"chartjs-chart-timeline": "0.4.0",
|
||||
"chart.js": "3.3.2",
|
||||
"chartjs-adapter-date-fns": "2.0.0",
|
||||
"cheerio": "1.0.0-rc.6",
|
||||
"class-transformer": "0.3.2",
|
||||
"class-validator": "0.13.1",
|
||||
"countries-list": "2.6.1",
|
||||
"countup.js": "2.0.7",
|
||||
"cryptocurrencies": "7.0.0",
|
||||
"date-fns": "2.19.0",
|
||||
"date-fns": "2.22.1",
|
||||
"envalid": "7.1.0",
|
||||
"http-status-codes": "2.1.4",
|
||||
"ionicons": "5.5.1",
|
||||
"lodash": "4.17.21",
|
||||
"ngx-device-detector": "2.0.6",
|
||||
"ngx-markdown": "11.1.2",
|
||||
"ngx-device-detector": "2.1.1",
|
||||
"ngx-markdown": "12.0.1",
|
||||
"ngx-skeleton-loader": "2.9.1",
|
||||
"ngx-stripe": "12.0.2",
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
@ -97,50 +101,52 @@
|
||||
"reflect-metadata": "0.1.13",
|
||||
"round-to": "5.0.0",
|
||||
"rxjs": "6.6.7",
|
||||
"stripe": "8.156.0",
|
||||
"svgmap": "2.1.1",
|
||||
"uuid": "8.3.2",
|
||||
"yahoo-finance": "0.3.6",
|
||||
"zone.js": "0.11.4"
|
||||
"zone.js": "~0.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "0.1102.3",
|
||||
"@angular-eslint/eslint-plugin": "2.0.2",
|
||||
"@angular/cli": "11.2.3",
|
||||
"@angular/compiler-cli": "11.2.4",
|
||||
"@angular/language-service": "11.2.4",
|
||||
"@angular-devkit/build-angular": "12.0.4",
|
||||
"@angular-eslint/eslint-plugin": "12.0.0",
|
||||
"@angular/cli": "12.0.4",
|
||||
"@angular/compiler-cli": "12.0.4",
|
||||
"@angular/language-service": "12.0.4",
|
||||
"@angular/localize": "11.0.9",
|
||||
"@nestjs/schematics": "7.2.6",
|
||||
"@nestjs/testing": "7.6.5",
|
||||
"@nrwl/cli": "12.0.0",
|
||||
"@nrwl/cypress": "12.0.0",
|
||||
"@nrwl/eslint-plugin-nx": "12.0.0",
|
||||
"@nrwl/jest": "12.0.0",
|
||||
"@nrwl/nest": "12.0.0",
|
||||
"@nrwl/node": "12.0.0",
|
||||
"@nrwl/tao": "12.0.0",
|
||||
"@nrwl/workspace": "12.0.0",
|
||||
"@nrwl/cli": "12.3.6",
|
||||
"@nrwl/cypress": "12.3.6",
|
||||
"@nrwl/eslint-plugin-nx": "12.3.6",
|
||||
"@nrwl/jest": "12.3.6",
|
||||
"@nrwl/nest": "12.3.6",
|
||||
"@nrwl/node": "12.3.6",
|
||||
"@nrwl/tao": "12.3.6",
|
||||
"@nrwl/workspace": "12.3.6",
|
||||
"@types/cache-manager": "3.4.0",
|
||||
"@types/jest": "26.0.20",
|
||||
"@types/node": "14.14.33",
|
||||
"@types/passport-google-oauth20": "2.0.6",
|
||||
"@typescript-eslint/eslint-plugin": "4.19.0",
|
||||
"@typescript-eslint/parser": "4.19.0",
|
||||
"@typescript-eslint/eslint-plugin": "4.27.0",
|
||||
"@typescript-eslint/parser": "4.27.0",
|
||||
"codelyzer": "6.0.1",
|
||||
"cypress": "6.2.1",
|
||||
"eslint": "7.22.0",
|
||||
"eslint-config-prettier": "8.1.0",
|
||||
"eslint-plugin-import": "latest",
|
||||
"dotenv": "8.2.0",
|
||||
"eslint": "7.28.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-import": "2.23.4",
|
||||
"import-sort-cli": "6.0.0",
|
||||
"import-sort-parser-typescript": "6.0.0",
|
||||
"import-sort-style-module": "6.0.0",
|
||||
"jest": "26.6.3",
|
||||
"jest-preset-angular": "8.3.2",
|
||||
"prettier": "2.2.1",
|
||||
"jest-preset-angular": "8.4.0",
|
||||
"prettier": "2.3.1",
|
||||
"replace-in-file": "6.2.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "26.4.4",
|
||||
"ts-jest": "26.5.5",
|
||||
"ts-node": "9.1.1",
|
||||
"typescript": "4.1.4"
|
||||
"typescript": "4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "14.x"
|
||||
|
@ -0,0 +1,18 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "authChallenge" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuthDevice" (
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"credentialId" BYTEA NOT NULL,
|
||||
"credentialPublicKey" BYTEA NOT NULL,
|
||||
"counter" INTEGER NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuthDevice" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SymbolProfile" ADD COLUMN "sectors" JSONB;
|
@ -47,6 +47,17 @@ model Analytics {
|
||||
userId String @id
|
||||
}
|
||||
|
||||
model AuthDevice {
|
||||
createdAt DateTime @default(now())
|
||||
credentialId Bytes
|
||||
credentialPublicKey Bytes
|
||||
counter Int
|
||||
id String @id @default(uuid())
|
||||
updatedAt DateTime @updatedAt
|
||||
User User @relation(fields: [userId], references: [id])
|
||||
userId String
|
||||
}
|
||||
|
||||
model MarketData {
|
||||
createdAt DateTime @default(now())
|
||||
date DateTime
|
||||
@ -109,6 +120,7 @@ model SymbolProfile {
|
||||
name String?
|
||||
Order Order[]
|
||||
updatedAt DateTime @updatedAt
|
||||
sectors Json?
|
||||
symbol String
|
||||
|
||||
@@unique([dataSource, symbol])
|
||||
@ -126,21 +138,23 @@ model Subscription {
|
||||
}
|
||||
|
||||
model User {
|
||||
Access Access[] @relation("accessGet")
|
||||
AccessGive Access[] @relation(name: "accessGive")
|
||||
accessToken String?
|
||||
Account Account[]
|
||||
alias String?
|
||||
Analytics Analytics?
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
Order Order[]
|
||||
provider Provider?
|
||||
role Role @default(USER)
|
||||
Settings Settings?
|
||||
Subscription Subscription[]
|
||||
thirdPartyId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
Access Access[] @relation("accessGet")
|
||||
AccessGive Access[] @relation(name: "accessGive")
|
||||
accessToken String?
|
||||
Account Account[]
|
||||
alias String?
|
||||
Analytics Analytics?
|
||||
authChallenge String?
|
||||
AuthDevice AuthDevice[]
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
Order Order[]
|
||||
provider Provider?
|
||||
role Role @default(USER)
|
||||
Settings Settings?
|
||||
Subscription Subscription[]
|
||||
thirdPartyId String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum AccountType {
|
||||
|
@ -142,18 +142,21 @@ async function main() {
|
||||
countries: [{ code: 'US', weight: 1 }],
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: '2bd26362-136e-411c-b578-334084b4cdcc',
|
||||
sectors: [{ name: 'Consumer Cyclical', weight: 1 }],
|
||||
symbol: 'AMZN'
|
||||
},
|
||||
{
|
||||
countries: null,
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: 'fdc42ea6-1321-44f5-9fb0-d7f1f2cf9b1e',
|
||||
sectors: null,
|
||||
symbol: 'BTCUSD'
|
||||
},
|
||||
{
|
||||
countries: [{ code: 'US', weight: 1 }],
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
|
||||
sectors: [{ name: 'Consumer Cyclical', weight: 1 }],
|
||||
symbol: 'TSLA'
|
||||
},
|
||||
{
|
||||
@ -164,6 +167,21 @@ async function main() {
|
||||
],
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||
sectors: [
|
||||
{ name: 'Technology', weight: 0.31393799999999955 },
|
||||
{ name: 'Consumer Cyclical', weight: 0.149224 },
|
||||
{ name: 'Financials', weight: 0.11716100000000002 },
|
||||
{ name: 'Healthcare', weight: 0.13285199999999994 },
|
||||
{ name: 'Consumer Staples', weight: 0.053919000000000016 },
|
||||
{ name: 'Energy', weight: 0.025529999999999997 },
|
||||
{ name: 'Telecommunications', weight: 0.012579 },
|
||||
{ name: 'Industrials', weight: 0.09526399999999995 },
|
||||
{ name: 'Utilities', weight: 0.024791999999999988 },
|
||||
{ name: 'Materials', weight: 0.027664 },
|
||||
{ name: 'Real Estate', weight: 0.03239999999999998 },
|
||||
{ name: 'Communication', weight: 0.0036139999999999996 },
|
||||
{ name: 'Other', weight: 0.000218 }
|
||||
],
|
||||
symbol: 'VTI'
|
||||
}
|
||||
],
|
||||
|
@ -1,4 +1,11 @@
|
||||
const dotenv = require('dotenv');
|
||||
const path = require('path');
|
||||
const replace = require('replace-in-file');
|
||||
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, '.env')
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const buildTimestamp = `${formatWithTwoDigits(
|
||||
now.getDate()
|
||||
@ -7,17 +14,24 @@ const buildTimestamp = `${formatWithTwoDigits(
|
||||
)}.${now.getFullYear()} ${formatWithTwoDigits(
|
||||
now.getHours()
|
||||
)}:${formatWithTwoDigits(now.getMinutes())}`;
|
||||
const options = {
|
||||
files: './dist/apps/client/main.*.js',
|
||||
from: /{BUILD_TIMESTAMP}/g,
|
||||
to: buildTimestamp,
|
||||
allowEmptyPaths: false
|
||||
};
|
||||
|
||||
try {
|
||||
const changedFiles = replace.sync(options);
|
||||
let changedFiles = replace.sync({
|
||||
files: './dist/apps/client/main.*.js',
|
||||
from: /{BUILD_TIMESTAMP}/g,
|
||||
to: buildTimestamp,
|
||||
allowEmptyPaths: false
|
||||
});
|
||||
console.log('Build version set: ' + buildTimestamp);
|
||||
console.log(changedFiles);
|
||||
|
||||
changedFiles = replace.sync({
|
||||
files: './dist/apps/client/main.*.js',
|
||||
from: /{STRIPE_PUBLIC_KEY}/g,
|
||||
to: process.env.STRIPE_PUBLIC_KEY ?? '',
|
||||
allowEmptyPaths: false
|
||||
});
|
||||
console.log(changedFiles);
|
||||
} catch (error) {
|
||||
console.error('Error occurred:', error);
|
||||
}
|
||||
|
Reference in New Issue
Block a user