Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
3b12e5b85b | |||
6c1119caec | |||
698d5ec3b7 | |||
e87c942cb8 | |||
f7860a9799 | |||
c519eb0e99 | |||
8314b98f81 | |||
194cf1ddcc | |||
7da6478699 | |||
4f2bbba782 | |||
9eb25f6c9e | |||
f74b00446c | |||
beb7e6ec34 | |||
2eafc042ad | |||
74954bc51d | |||
6a03120225 | |||
21504573b4 | |||
fabd912fba | |||
00b42855b6 | |||
ef272360fb | |||
026a5011d4 | |||
aa4206af0e |
74
CHANGELOG.md
74
CHANGELOG.md
@ -5,6 +5,66 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.16.0 - 14.06.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the sign in with fingerprint
|
||||||
|
|
||||||
|
## 1.15.0 - 14.06.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a counter column to the transactions table
|
||||||
|
- Added a label to indicate the default account in the accounts table
|
||||||
|
- Added an option to limit the items in pie charts
|
||||||
|
- Added sign in with fingerprint
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Cleaned up the analysis page with an unused chart module
|
||||||
|
- Improved the cell alignment in the users table of the admin control panel
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the last activity column of users in the admin control panel
|
||||||
|
|
||||||
|
## 1.14.0 - 09.06.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a connect or create symbol profile model logic on creating a new transaction
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the global heat map to visualize investments by country
|
||||||
|
|
||||||
|
## 1.13.0 - 08.06.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a global heat map to visualize investments by country
|
||||||
|
|
||||||
|
## 1.12.0 - 06.06.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a symbol profile model with additional data
|
||||||
|
- Added new pie charts: Investments by continent and country
|
||||||
|
|
||||||
|
## 1.11.0 - 05.06.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a dedicated page for the account registration
|
||||||
|
- Rendered the average buy prices in the position detail chart (useful for recurring transactions)
|
||||||
|
- Introduced the initial prisma migration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the buttons to links (`<a>`) on the tools page
|
||||||
|
- Upgraded `prisma` from version `2.20.1` to `2.24.1`
|
||||||
|
|
||||||
## 1.10.1 - 02.06.2021
|
## 1.10.1 - 02.06.2021
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@ -64,11 +124,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added an index in the user table of the admin control panel
|
- Added an index in the users table of the admin control panel
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the alignment in the user table of the admin control panel
|
- Improved the alignment in the users table of the admin control panel
|
||||||
|
|
||||||
## 1.5.0 - 22.05.2021
|
## 1.5.0 - 22.05.2021
|
||||||
|
|
||||||
@ -200,7 +260,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the user table styling of the admin control panel
|
- Improved the users table styling of the admin control panel
|
||||||
- Improved the background colors in the dark mode
|
- Improved the background colors in the dark mode
|
||||||
|
|
||||||
## 0.92.0 - 25.04.2021
|
## 0.92.0 - 25.04.2021
|
||||||
@ -208,7 +268,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Prepared further for multi accounts support: store account for new transactions
|
- Prepared further for multi accounts support: store account for new transactions
|
||||||
- Added a horizontal scrollbar to the user table of the admin control panel
|
- Added a horizontal scrollbar to the users table of the admin control panel
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@ -235,7 +295,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Improved the user table of the admin control panel
|
- Improved the users table of the admin control panel
|
||||||
|
|
||||||
## 0.89.0 - 21.04.2021
|
## 0.89.0 - 21.04.2021
|
||||||
|
|
||||||
@ -266,7 +326,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed an issue in the user table of the admin control panel with missing data
|
- Fixed an issue in the users table of the admin control panel with missing data
|
||||||
|
|
||||||
## 0.86.1 - 18.04.2021
|
## 0.86.1 - 18.04.2021
|
||||||
|
|
||||||
@ -281,7 +341,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Changed the about page for the new license
|
- Changed the about page for the new license
|
||||||
- Optimized the data management for historical data
|
- Optimized the data management for historical data
|
||||||
- Optimized the exchange rate service
|
- Optimized the exchange rate service
|
||||||
- Improved the user table of the admin control panel
|
- Improved the users table of the admin control panel
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
@ -88,12 +88,14 @@ Please make sure you have completed the instructions from [_Setup_](#Setup)
|
|||||||
|
|
||||||
### Start server
|
### Start server
|
||||||
|
|
||||||
- Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_
|
<ol type="a">
|
||||||
- Serve: Run `yarn start:server`
|
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
|
||||||
|
<li>Serve: Run <code>yarn start:server</code></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
### Start client
|
### Start client
|
||||||
|
|
||||||
- Run `yarn start:client`
|
Run `yarn start:client`
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
|
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
@ -34,6 +35,7 @@ import { UserModule } from './user/user.module';
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
AccessModule,
|
AccessModule,
|
||||||
AccountModule,
|
AccountModule,
|
||||||
|
AuthDeviceModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CacheModule,
|
CacheModule,
|
||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot(),
|
||||||
|
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
44
apps/api/src/app/auth-device/auth-device.controller.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
|
import {
|
||||||
|
getPermissions,
|
||||||
|
hasPermission,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpException,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
UseGuards
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
|
@Controller('auth-device')
|
||||||
|
export class AuthDeviceController {
|
||||||
|
public constructor(
|
||||||
|
private readonly authDeviceService: AuthDeviceService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.deleteAuthDevice
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.authDeviceService.deleteAuthDevice({ id });
|
||||||
|
}
|
||||||
|
}
|
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
4
apps/api/src/app/auth-device/auth-device.dto.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface AuthDeviceDto {
|
||||||
|
createdAt: string;
|
||||||
|
id: string;
|
||||||
|
}
|
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
18
apps/api/src/app/auth-device/auth-device.module.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||||
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthDeviceController],
|
||||||
|
imports: [
|
||||||
|
JwtModule.register({
|
||||||
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
|
signOptions: { expiresIn: '180 days' }
|
||||||
|
})
|
||||||
|
],
|
||||||
|
providers: [AuthDeviceService, ConfigurationService, PrismaService]
|
||||||
|
})
|
||||||
|
export class AuthDeviceModule {}
|
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
65
apps/api/src/app/auth-device/auth-device.service.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { AuthDevice, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthDeviceService {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private prisma: PrismaService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public async authDevice(
|
||||||
|
where: Prisma.AuthDeviceWhereUniqueInput
|
||||||
|
): Promise<AuthDevice | null> {
|
||||||
|
return this.prisma.authDevice.findUnique({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async authDevices(params: {
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
cursor?: Prisma.AuthDeviceWhereUniqueInput;
|
||||||
|
where?: Prisma.AuthDeviceWhereInput;
|
||||||
|
orderBy?: Prisma.AuthDeviceOrderByInput;
|
||||||
|
}): Promise<AuthDevice[]> {
|
||||||
|
const { skip, take, cursor, where, orderBy } = params;
|
||||||
|
return this.prisma.authDevice.findMany({
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
cursor,
|
||||||
|
where,
|
||||||
|
orderBy
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAuthDevice(
|
||||||
|
data: Prisma.AuthDeviceCreateInput
|
||||||
|
): Promise<AuthDevice> {
|
||||||
|
return this.prisma.authDevice.create({
|
||||||
|
data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async updateAuthDevice(params: {
|
||||||
|
data: Prisma.AuthDeviceUpdateInput;
|
||||||
|
where: Prisma.AuthDeviceWhereUniqueInput;
|
||||||
|
}): Promise<AuthDevice> {
|
||||||
|
const { data, where } = params;
|
||||||
|
|
||||||
|
return this.prisma.authDevice.update({
|
||||||
|
data,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteAuthDevice(
|
||||||
|
where: Prisma.AuthDeviceWhereUniqueInput
|
||||||
|
): Promise<AuthDevice> {
|
||||||
|
return this.prisma.authDevice.delete({
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,12 @@
|
|||||||
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Param,
|
Param,
|
||||||
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UseGuards
|
UseGuards
|
||||||
@ -12,12 +15,17 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
import {
|
||||||
|
AssertionCredentialJSON,
|
||||||
|
AttestationCredentialJSON
|
||||||
|
} from './interfaces/simplewebauthn';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly webAuthService: WebAuthService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('anonymous/:accessToken')
|
@Get('anonymous/:accessToken')
|
||||||
@ -53,4 +61,44 @@ export class AuthController {
|
|||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('webauthn/generate-attestation-options')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async generateAttestationOptions() {
|
||||||
|
return this.webAuthService.generateAttestationOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('webauthn/verify-attestation')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async verifyAttestation(
|
||||||
|
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||||
|
) {
|
||||||
|
return this.webAuthService.verifyAttestation(
|
||||||
|
body.deviceName,
|
||||||
|
body.credential
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('webauthn/generate-assertion-options')
|
||||||
|
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
|
||||||
|
return this.webAuthService.generateAssertionOptions(body.deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('webauthn/verify-assertion')
|
||||||
|
public async verifyAssertion(
|
||||||
|
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const authToken = await this.webAuthService.verifyAssertion(
|
||||||
|
body.deviceId,
|
||||||
|
body.credential
|
||||||
|
);
|
||||||
|
return { authToken };
|
||||||
|
} catch {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
|
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
@ -18,12 +20,14 @@ import { JwtStrategy } from './jwt.strategy';
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
AuthDeviceService,
|
||||||
AuthService,
|
AuthService,
|
||||||
ConfigurationService,
|
ConfigurationService,
|
||||||
GoogleStrategy,
|
GoogleStrategy,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
UserService
|
UserService,
|
||||||
|
WebAuthService
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
|
|
||||||
|
export interface AuthDeviceDialogParams {
|
||||||
|
authDevice: AuthDeviceDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ValidateOAuthLoginParams {
|
export interface ValidateOAuthLoginParams {
|
||||||
provider: Provider;
|
provider: Provider;
|
||||||
thirdPartyId: string;
|
thirdPartyId: string;
|
||||||
|
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
226
apps/api/src/app/auth/interfaces/simplewebauthn.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
|
||||||
|
readonly authenticatorData: ArrayBuffer;
|
||||||
|
readonly signature: ArrayBuffer;
|
||||||
|
readonly userHandle: ArrayBuffer | null;
|
||||||
|
}
|
||||||
|
export interface AuthenticatorAttestationResponse
|
||||||
|
extends AuthenticatorResponse {
|
||||||
|
readonly attestationObject: ArrayBuffer;
|
||||||
|
}
|
||||||
|
export interface AuthenticationExtensionsClientInputs {
|
||||||
|
appid?: string;
|
||||||
|
appidExclude?: string;
|
||||||
|
credProps?: boolean;
|
||||||
|
uvm?: boolean;
|
||||||
|
}
|
||||||
|
export interface AuthenticationExtensionsClientOutputs {
|
||||||
|
appid?: boolean;
|
||||||
|
credProps?: CredentialPropertiesOutput;
|
||||||
|
uvm?: UvmEntries;
|
||||||
|
}
|
||||||
|
export interface AuthenticatorSelectionCriteria {
|
||||||
|
authenticatorAttachment?: AuthenticatorAttachment;
|
||||||
|
requireResidentKey?: boolean;
|
||||||
|
residentKey?: ResidentKeyRequirement;
|
||||||
|
userVerification?: UserVerificationRequirement;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredential extends Credential {
|
||||||
|
readonly rawId: ArrayBuffer;
|
||||||
|
readonly response: AuthenticatorResponse;
|
||||||
|
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredentialCreationOptions {
|
||||||
|
attestation?: AttestationConveyancePreference;
|
||||||
|
authenticatorSelection?: AuthenticatorSelectionCriteria;
|
||||||
|
challenge: BufferSource;
|
||||||
|
excludeCredentials?: PublicKeyCredentialDescriptor[];
|
||||||
|
extensions?: AuthenticationExtensionsClientInputs;
|
||||||
|
pubKeyCredParams: PublicKeyCredentialParameters[];
|
||||||
|
rp: PublicKeyCredentialRpEntity;
|
||||||
|
timeout?: number;
|
||||||
|
user: PublicKeyCredentialUserEntity;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredentialDescriptor {
|
||||||
|
id: BufferSource;
|
||||||
|
transports?: AuthenticatorTransport[];
|
||||||
|
type: PublicKeyCredentialType;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredentialParameters {
|
||||||
|
alg: COSEAlgorithmIdentifier;
|
||||||
|
type: PublicKeyCredentialType;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredentialRequestOptions {
|
||||||
|
allowCredentials?: PublicKeyCredentialDescriptor[];
|
||||||
|
challenge: BufferSource;
|
||||||
|
extensions?: AuthenticationExtensionsClientInputs;
|
||||||
|
rpId?: string;
|
||||||
|
timeout?: number;
|
||||||
|
userVerification?: UserVerificationRequirement;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredentialUserEntity
|
||||||
|
extends PublicKeyCredentialEntity {
|
||||||
|
displayName: string;
|
||||||
|
id: BufferSource;
|
||||||
|
}
|
||||||
|
export interface AuthenticatorResponse {
|
||||||
|
readonly clientDataJSON: ArrayBuffer;
|
||||||
|
}
|
||||||
|
export interface CredentialPropertiesOutput {
|
||||||
|
rk?: boolean;
|
||||||
|
}
|
||||||
|
export interface Credential {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: string;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredentialEntity {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
export declare type AttestationConveyancePreference =
|
||||||
|
| 'direct'
|
||||||
|
| 'enterprise'
|
||||||
|
| 'indirect'
|
||||||
|
| 'none';
|
||||||
|
export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb';
|
||||||
|
export declare type COSEAlgorithmIdentifier = number;
|
||||||
|
export declare type UserVerificationRequirement =
|
||||||
|
| 'discouraged'
|
||||||
|
| 'preferred'
|
||||||
|
| 'required';
|
||||||
|
export declare type UvmEntries = UvmEntry[];
|
||||||
|
export declare type AuthenticatorAttachment = 'cross-platform' | 'platform';
|
||||||
|
export declare type ResidentKeyRequirement =
|
||||||
|
| 'discouraged'
|
||||||
|
| 'preferred'
|
||||||
|
| 'required';
|
||||||
|
export declare type BufferSource = ArrayBufferView | ArrayBuffer;
|
||||||
|
export declare type PublicKeyCredentialType = 'public-key';
|
||||||
|
export declare type UvmEntry = number[];
|
||||||
|
|
||||||
|
export interface PublicKeyCredentialCreationOptionsJSON
|
||||||
|
extends Omit<
|
||||||
|
PublicKeyCredentialCreationOptions,
|
||||||
|
'challenge' | 'user' | 'excludeCredentials'
|
||||||
|
> {
|
||||||
|
user: PublicKeyCredentialUserEntityJSON;
|
||||||
|
challenge: Base64URLString;
|
||||||
|
excludeCredentials: PublicKeyCredentialDescriptorJSON[];
|
||||||
|
extensions?: AuthenticationExtensionsClientInputs;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
|
||||||
|
* (eventually) get passed into navigator.credentials.get(...) in the browser.
|
||||||
|
*/
|
||||||
|
export interface PublicKeyCredentialRequestOptionsJSON
|
||||||
|
extends Omit<
|
||||||
|
PublicKeyCredentialRequestOptions,
|
||||||
|
'challenge' | 'allowCredentials'
|
||||||
|
> {
|
||||||
|
challenge: Base64URLString;
|
||||||
|
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
|
||||||
|
extensions?: AuthenticationExtensionsClientInputs;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredentialDescriptorJSON
|
||||||
|
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
|
||||||
|
id: Base64URLString;
|
||||||
|
}
|
||||||
|
export interface PublicKeyCredentialUserEntityJSON
|
||||||
|
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The value returned from navigator.credentials.create()
|
||||||
|
*/
|
||||||
|
export interface AttestationCredential extends PublicKeyCredential {
|
||||||
|
response: AuthenticatorAttestationResponseFuture;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that
|
||||||
|
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||||
|
*/
|
||||||
|
export interface AttestationCredentialJSON
|
||||||
|
extends Omit<
|
||||||
|
AttestationCredential,
|
||||||
|
'response' | 'rawId' | 'getClientExtensionResults'
|
||||||
|
> {
|
||||||
|
rawId: Base64URLString;
|
||||||
|
response: AuthenticatorAttestationResponseJSON;
|
||||||
|
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||||
|
transports?: AuthenticatorTransport[];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* The value returned from navigator.credentials.get()
|
||||||
|
*/
|
||||||
|
export interface AssertionCredential extends PublicKeyCredential {
|
||||||
|
response: AuthenticatorAssertionResponse;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that
|
||||||
|
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||||
|
*/
|
||||||
|
export interface AssertionCredentialJSON
|
||||||
|
extends Omit<
|
||||||
|
AssertionCredential,
|
||||||
|
'response' | 'rawId' | 'getClientExtensionResults'
|
||||||
|
> {
|
||||||
|
rawId: Base64URLString;
|
||||||
|
response: AuthenticatorAssertionResponseJSON;
|
||||||
|
clientExtensionResults: AuthenticationExtensionsClientOutputs;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that
|
||||||
|
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||||
|
*/
|
||||||
|
export interface AuthenticatorAttestationResponseJSON
|
||||||
|
extends Omit<
|
||||||
|
AuthenticatorAttestationResponseFuture,
|
||||||
|
'clientDataJSON' | 'attestationObject'
|
||||||
|
> {
|
||||||
|
clientDataJSON: Base64URLString;
|
||||||
|
attestationObject: Base64URLString;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that
|
||||||
|
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
|
||||||
|
*/
|
||||||
|
export interface AuthenticatorAssertionResponseJSON
|
||||||
|
extends Omit<
|
||||||
|
AuthenticatorAssertionResponse,
|
||||||
|
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
|
||||||
|
> {
|
||||||
|
authenticatorData: Base64URLString;
|
||||||
|
clientDataJSON: Base64URLString;
|
||||||
|
signature: Base64URLString;
|
||||||
|
userHandle?: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* A WebAuthn-compatible device and the information needed to verify assertions by it
|
||||||
|
*/
|
||||||
|
export declare type AuthenticatorDevice = {
|
||||||
|
credentialPublicKey: Buffer;
|
||||||
|
credentialID: Buffer;
|
||||||
|
counter: number;
|
||||||
|
transports?: AuthenticatorTransport[];
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
|
||||||
|
*/
|
||||||
|
export declare type Base64URLString = string;
|
||||||
|
/**
|
||||||
|
* AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7).
|
||||||
|
* Maintain an augmented version here so we can implement additional properties as the WebAuthn
|
||||||
|
* spec evolves.
|
||||||
|
*
|
||||||
|
* See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse
|
||||||
|
*
|
||||||
|
* Properties marked optional are not supported in all browsers.
|
||||||
|
*/
|
||||||
|
export interface AuthenticatorAttestationResponseFuture
|
||||||
|
extends AuthenticatorAttestationResponse {
|
||||||
|
getTransports?: () => AuthenticatorTransport[];
|
||||||
|
getAuthenticatorData?: () => ArrayBuffer;
|
||||||
|
getPublicKey?: () => ArrayBuffer;
|
||||||
|
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
|
||||||
|
}
|
216
apps/api/src/app/auth/web-auth.service.ts
Normal file
216
apps/api/src/app/auth/web-auth.service.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||||
|
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import {
|
||||||
|
GenerateAssertionOptionsOpts,
|
||||||
|
GenerateAttestationOptionsOpts,
|
||||||
|
VerifiedAssertion,
|
||||||
|
VerifiedAttestation,
|
||||||
|
VerifyAssertionResponseOpts,
|
||||||
|
VerifyAttestationResponseOpts,
|
||||||
|
generateAssertionOptions,
|
||||||
|
generateAttestationOptions,
|
||||||
|
verifyAssertionResponse,
|
||||||
|
verifyAttestationResponse
|
||||||
|
} from '@simplewebauthn/server';
|
||||||
|
|
||||||
|
import { UserService } from '../user/user.service';
|
||||||
|
import {
|
||||||
|
AssertionCredentialJSON,
|
||||||
|
AttestationCredentialJSON
|
||||||
|
} from './interfaces/simplewebauthn';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WebAuthService {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly deviceService: AuthDeviceService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly userService: UserService,
|
||||||
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get rpID() {
|
||||||
|
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
get expectedOrigin() {
|
||||||
|
return this.configurationService.get('ROOT_URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async generateAttestationOptions() {
|
||||||
|
const user = this.request.user;
|
||||||
|
|
||||||
|
const opts: GenerateAttestationOptionsOpts = {
|
||||||
|
rpName: 'Ghostfolio',
|
||||||
|
rpID: this.rpID,
|
||||||
|
userID: user.id,
|
||||||
|
userName: user.alias,
|
||||||
|
timeout: 60000,
|
||||||
|
attestationType: 'indirect',
|
||||||
|
authenticatorSelection: {
|
||||||
|
authenticatorAttachment: 'platform',
|
||||||
|
requireResidentKey: false,
|
||||||
|
userVerification: 'required'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = generateAttestationOptions(opts);
|
||||||
|
|
||||||
|
await this.userService.updateUser({
|
||||||
|
data: {
|
||||||
|
authChallenge: options.challenge
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: user.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async verifyAttestation(
|
||||||
|
deviceName: string,
|
||||||
|
credential: AttestationCredentialJSON
|
||||||
|
): Promise<AuthDeviceDto> {
|
||||||
|
const user = this.request.user;
|
||||||
|
const expectedChallenge = user.authChallenge;
|
||||||
|
|
||||||
|
let verification: VerifiedAttestation;
|
||||||
|
try {
|
||||||
|
const opts: VerifyAttestationResponseOpts = {
|
||||||
|
credential,
|
||||||
|
expectedChallenge,
|
||||||
|
expectedOrigin: this.expectedOrigin,
|
||||||
|
expectedRPID: this.rpID
|
||||||
|
};
|
||||||
|
verification = await verifyAttestationResponse(opts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
throw new InternalServerErrorException(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { verified, attestationInfo } = verification;
|
||||||
|
|
||||||
|
const devices = await this.deviceService.authDevices({
|
||||||
|
where: { userId: user.id }
|
||||||
|
});
|
||||||
|
if (verified && attestationInfo) {
|
||||||
|
const { credentialPublicKey, credentialID, counter } = attestationInfo;
|
||||||
|
|
||||||
|
let existingDevice = devices.find(
|
||||||
|
(device) => device.credentialId === credentialID
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingDevice) {
|
||||||
|
/**
|
||||||
|
* Add the returned device to the user's list of devices
|
||||||
|
*/
|
||||||
|
existingDevice = await this.deviceService.createAuthDevice({
|
||||||
|
credentialPublicKey,
|
||||||
|
credentialId: credentialID,
|
||||||
|
counter,
|
||||||
|
User: { connect: { id: user.id } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createdAt: existingDevice.createdAt.toISOString(),
|
||||||
|
id: existingDevice.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InternalServerErrorException('An unknown error occurred');
|
||||||
|
}
|
||||||
|
|
||||||
|
public async generateAssertionOptions(deviceId: string) {
|
||||||
|
const device = await this.deviceService.authDevice({ id: deviceId });
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new Error('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts: GenerateAssertionOptionsOpts = {
|
||||||
|
timeout: 60000,
|
||||||
|
allowCredentials: [
|
||||||
|
{
|
||||||
|
id: device.credentialId,
|
||||||
|
type: 'public-key',
|
||||||
|
transports: ['internal']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
userVerification: 'preferred',
|
||||||
|
rpID: this.rpID
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = generateAssertionOptions(opts);
|
||||||
|
|
||||||
|
await this.userService.updateUser({
|
||||||
|
data: {
|
||||||
|
authChallenge: options.challenge
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: device.userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async verifyAssertion(
|
||||||
|
deviceId: string,
|
||||||
|
credential: AssertionCredentialJSON
|
||||||
|
) {
|
||||||
|
const device = await this.deviceService.authDevice({ id: deviceId });
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
throw new Error('Device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.userService.user({ id: device.userId });
|
||||||
|
|
||||||
|
let verification: VerifiedAssertion;
|
||||||
|
try {
|
||||||
|
const opts: VerifyAssertionResponseOpts = {
|
||||||
|
credential,
|
||||||
|
expectedChallenge: `${user.authChallenge}`,
|
||||||
|
expectedOrigin: this.expectedOrigin,
|
||||||
|
expectedRPID: this.rpID,
|
||||||
|
authenticator: {
|
||||||
|
credentialID: device.credentialId,
|
||||||
|
credentialPublicKey: device.credentialPublicKey,
|
||||||
|
counter: device.counter
|
||||||
|
}
|
||||||
|
};
|
||||||
|
verification = verifyAssertionResponse(opts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { verified, assertionInfo } = verification;
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
device.counter = assertionInfo.newCounter;
|
||||||
|
|
||||||
|
await this.deviceService.updateAuthDevice({
|
||||||
|
data: device,
|
||||||
|
where: { id: device.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.jwtService.sign({
|
||||||
|
id: user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
}
|
@ -44,6 +44,7 @@ export class ExperimentalService {
|
|||||||
fee: 0,
|
fee: 0,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
platformId: undefined,
|
platformId: undefined,
|
||||||
|
symbolProfileId: undefined,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
updatedAt: undefined,
|
updatedAt: undefined,
|
||||||
userId: undefined
|
userId: undefined
|
||||||
|
@ -132,12 +132,26 @@ export class OrderController {
|
|||||||
return this.orderService.createOrder(
|
return this.orderService.createOrder(
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
date,
|
|
||||||
Account: {
|
Account: {
|
||||||
connect: {
|
connect: {
|
||||||
id_userId: { id: accountId, userId: this.request.user.id }
|
id_userId: { id: accountId, userId: this.request.user.id }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
date,
|
||||||
|
SymbolProfile: {
|
||||||
|
connectOrCreate: {
|
||||||
|
where: {
|
||||||
|
dataSource_symbol: {
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
symbol: data.symbol
|
||||||
|
}
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
symbol: data.symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
User: { connect: { id: this.request.user.id } }
|
User: { connect: { id: this.request.user.id } }
|
||||||
},
|
},
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
getYear,
|
getYear,
|
||||||
isAfter,
|
isAfter,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
|
parse,
|
||||||
parseISO,
|
parseISO,
|
||||||
setDate,
|
setDate,
|
||||||
setMonth,
|
setMonth,
|
||||||
@ -75,7 +76,8 @@ export class PortfolioService {
|
|||||||
// Get portfolio from database
|
// Get portfolio from database
|
||||||
const orders = await this.orderService.orders({
|
const orders = await this.orderService.orders({
|
||||||
include: {
|
include: {
|
||||||
Account: true
|
Account: true,
|
||||||
|
SymbolProfile: true
|
||||||
},
|
},
|
||||||
orderBy: { date: 'asc' },
|
orderBy: { date: 'asc' },
|
||||||
where: { userId: aUserId }
|
where: { userId: aUserId }
|
||||||
@ -215,6 +217,8 @@ export class PortfolioService {
|
|||||||
transactionCount
|
transactionCount
|
||||||
} = portfolio.getPositions(new Date())[aSymbol];
|
} = portfolio.getPositions(new Date())[aSymbol];
|
||||||
|
|
||||||
|
const orders = portfolio.getOrders(aSymbol);
|
||||||
|
|
||||||
const historicalData = await this.dataProviderService.getHistorical(
|
const historicalData = await this.dataProviderService.getHistorical(
|
||||||
[aSymbol],
|
[aSymbol],
|
||||||
'day',
|
'day',
|
||||||
@ -227,6 +231,7 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const historicalDataArray: HistoricalDataItem[] = [];
|
const historicalDataArray: HistoricalDataItem[] = [];
|
||||||
|
let currentAveragePrice: number;
|
||||||
let maxPrice = marketPrice;
|
let maxPrice = marketPrice;
|
||||||
let minPrice = marketPrice;
|
let minPrice = marketPrice;
|
||||||
|
|
||||||
@ -234,9 +239,24 @@ export class PortfolioService {
|
|||||||
for (const [date, { marketPrice }] of Object.entries(
|
for (const [date, { marketPrice }] of Object.entries(
|
||||||
historicalData[aSymbol]
|
historicalData[aSymbol]
|
||||||
)) {
|
)) {
|
||||||
|
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
|
||||||
|
if (
|
||||||
|
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
|
||||||
|
isAfter(currentDate, parseISO(orders[0]?.getDate()))
|
||||||
|
) {
|
||||||
|
// Get snapshot of first day of month
|
||||||
|
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
|
||||||
|
.positions[aSymbol];
|
||||||
|
orders.shift();
|
||||||
|
|
||||||
|
if (snapshot?.averagePrice) {
|
||||||
|
currentAveragePrice = snapshot?.averagePrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
historicalDataArray.push({
|
historicalDataArray.push({
|
||||||
averagePrice,
|
|
||||||
date,
|
date,
|
||||||
|
averagePrice: currentAveragePrice,
|
||||||
value: marketPrice
|
value: marketPrice
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Account, Currency, Platform } from '@prisma/client';
|
import { Account, Currency, Platform, SymbolProfile } from '@prisma/client';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { IOrder } from '../services/interfaces/interfaces';
|
import { IOrder } from '../services/interfaces/interfaces';
|
||||||
@ -12,6 +12,7 @@ export class Order {
|
|||||||
private id: string;
|
private id: string;
|
||||||
private quantity: number;
|
private quantity: number;
|
||||||
private symbol: string;
|
private symbol: string;
|
||||||
|
private symbolProfile: SymbolProfile;
|
||||||
private total: number;
|
private total: number;
|
||||||
private type: OrderType;
|
private type: OrderType;
|
||||||
private unitPrice: number;
|
private unitPrice: number;
|
||||||
@ -24,6 +25,7 @@ export class Order {
|
|||||||
this.id = data.id || uuidv4();
|
this.id = data.id || uuidv4();
|
||||||
this.quantity = data.quantity;
|
this.quantity = data.quantity;
|
||||||
this.symbol = data.symbol;
|
this.symbol = data.symbol;
|
||||||
|
this.symbolProfile = data.symbolProfile;
|
||||||
this.type = data.type;
|
this.type = data.type;
|
||||||
this.unitPrice = data.unitPrice;
|
this.unitPrice = data.unitPrice;
|
||||||
|
|
||||||
@ -58,6 +60,10 @@ export class Order {
|
|||||||
return this.symbol;
|
return this.symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSymbolProfile() {
|
||||||
|
return this.symbolProfile;
|
||||||
|
}
|
||||||
|
|
||||||
public getTotal() {
|
public getTotal() {
|
||||||
return this.total;
|
return this.total;
|
||||||
}
|
}
|
||||||
|
@ -120,6 +120,7 @@ describe('Portfolio', () => {
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
alias: 'Test',
|
alias: 'Test',
|
||||||
|
authChallenge: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
id: USER_ID,
|
id: USER_ID,
|
||||||
provider: null,
|
provider: null,
|
||||||
@ -189,6 +190,7 @@ describe('Portfolio', () => {
|
|||||||
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
symbol: 'BTCUSD',
|
symbol: 'BTCUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 49631.24,
|
unitPrice: 49631.24,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -223,6 +225,7 @@ describe('Portfolio', () => {
|
|||||||
},
|
},
|
||||||
allocationCurrent: 1,
|
allocationCurrent: 1,
|
||||||
allocationInvestment: 1,
|
allocationInvestment: 1,
|
||||||
|
countries: [],
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
exchange: UNKNOWN_KEY,
|
exchange: UNKNOWN_KEY,
|
||||||
grossPerformance: 0,
|
grossPerformance: 0,
|
||||||
@ -290,6 +293,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 991.49,
|
unitPrice: 991.49,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -324,6 +328,7 @@ describe('Portfolio', () => {
|
|||||||
},
|
},
|
||||||
// allocationCurrent: 1,
|
// allocationCurrent: 1,
|
||||||
allocationInvestment: 1,
|
allocationInvestment: 1,
|
||||||
|
countries: [],
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
exchange: UNKNOWN_KEY,
|
exchange: UNKNOWN_KEY,
|
||||||
// grossPerformance: 0,
|
// grossPerformance: 0,
|
||||||
@ -385,6 +390,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 991.49,
|
unitPrice: 991.49,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -401,6 +407,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||||
quantity: 0.3,
|
quantity: 0.3,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 1050,
|
unitPrice: 1050,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -461,6 +468,7 @@ describe('Portfolio', () => {
|
|||||||
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
||||||
quantity: 0.05614682,
|
quantity: 0.05614682,
|
||||||
symbol: 'BTCUSD',
|
symbol: 'BTCUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 3562.089535970158,
|
unitPrice: 3562.089535970158,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -477,6 +485,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 991.49,
|
unitPrice: 991.49,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -550,6 +559,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 991.49,
|
unitPrice: 991.49,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -566,6 +576,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||||
quantity: 0.1,
|
quantity: 0.1,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.SELL,
|
type: Type.SELL,
|
||||||
unitPrice: 1050,
|
unitPrice: 1050,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -582,6 +593,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 1050,
|
unitPrice: 1050,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
|
@ -8,7 +8,10 @@ import {
|
|||||||
Position,
|
Position,
|
||||||
UserWithSettings
|
UserWithSettings
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
|
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { continents, countries } from 'countries-list';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
format,
|
format,
|
||||||
@ -127,6 +130,7 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
id,
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
symbol,
|
||||||
|
symbolProfile,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
}) => {
|
}) => {
|
||||||
@ -139,6 +143,7 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
id,
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
symbol,
|
||||||
|
symbolProfile,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
})
|
})
|
||||||
@ -204,6 +209,7 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
|
|
||||||
symbols.forEach((symbol) => {
|
symbols.forEach((symbol) => {
|
||||||
const accounts: PortfolioPosition['accounts'] = {};
|
const accounts: PortfolioPosition['accounts'] = {};
|
||||||
|
let countriesOfSymbol: Country[];
|
||||||
const [portfolioItem] = portfolioItems;
|
const [portfolioItem] = portfolioItems;
|
||||||
|
|
||||||
const ordersBySymbol = this.getOrders().filter((order) => {
|
const ordersBySymbol = this.getOrders().filter((order) => {
|
||||||
@ -243,6 +249,21 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
original: originalValueOfSymbol
|
original: originalValueOfSymbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
countriesOfSymbol = (
|
||||||
|
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
|
||||||
|
[]
|
||||||
|
).map((country) => {
|
||||||
|
const { code, weight } = country as Prisma.JsonObject;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: code as string,
|
||||||
|
continent:
|
||||||
|
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||||
|
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
||||||
|
weight: weight as number
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let now = portfolioItemsNow.positions[symbol].marketPrice;
|
let now = portfolioItemsNow.positions[symbol].marketPrice;
|
||||||
@ -289,6 +310,7 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
) / value,
|
) / value,
|
||||||
allocationInvestment:
|
allocationInvestment:
|
||||||
portfolioItem.positions[symbol].investment / investment,
|
portfolioItem.positions[symbol].investment / investment,
|
||||||
|
countries: countriesOfSymbol,
|
||||||
grossPerformance: roundTo(
|
grossPerformance: roundTo(
|
||||||
portfolioItemsNow.positions[symbol].quantity * (now - before),
|
portfolioItemsNow.positions[symbol].quantity * (now - before),
|
||||||
2
|
2
|
||||||
@ -296,7 +318,12 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
grossPerformancePercent: roundTo((now - before) / before, 4),
|
grossPerformancePercent: roundTo((now - before) / before, 4),
|
||||||
investment: portfolioItem.positions[symbol].investment,
|
investment: portfolioItem.positions[symbol].investment,
|
||||||
quantity: portfolioItem.positions[symbol].quantity,
|
quantity: portfolioItem.positions[symbol].quantity,
|
||||||
transactionCount: portfolioItem.positions[symbol].transactionCount
|
transactionCount: portfolioItem.positions[symbol].transactionCount,
|
||||||
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
|
portfolioItem.positions[symbol].quantity * now,
|
||||||
|
data[symbol]?.currency,
|
||||||
|
this.user.Settings.currency
|
||||||
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -486,7 +513,13 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
.reduce((previous, current) => previous + current, 0);
|
.reduce((previous, current) => previous + current, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOrders() {
|
public getOrders(aSymbol?: string) {
|
||||||
|
if (aSymbol) {
|
||||||
|
return this.orders.filter((order) => {
|
||||||
|
return order.getSymbol() === aSymbol;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return this.orders;
|
return this.orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -538,6 +571,7 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
fee: order.fee,
|
fee: order.fee,
|
||||||
quantity: order.quantity,
|
quantity: order.quantity,
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
|
symbolProfile: order.SymbolProfile,
|
||||||
type: <OrderType>order.type,
|
type: <OrderType>order.type,
|
||||||
unitPrice: order.unitPrice
|
unitPrice: order.unitPrice
|
||||||
})
|
})
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { bool, cleanEnv, json, num, port, str } from 'envalid';
|
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
||||||
|
|
||||||
import { Environment } from './interfaces/environment.interface';
|
import { Environment } from './interfaces/environment.interface';
|
||||||
|
|
||||||
@ -26,7 +26,8 @@ export class ConfigurationService {
|
|||||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: str({ default: 'localhost' }),
|
REDIS_HOST: str({ default: 'localhost' }),
|
||||||
REDIS_PORT: port({ default: 6379 }),
|
REDIS_PORT: port({ default: 6379 }),
|
||||||
ROOT_URL: str({ default: 'http://localhost:4200' })
|
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||||
|
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,4 +18,5 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
REDIS_PORT: number;
|
REDIS_PORT: number;
|
||||||
ROOT_URL: string;
|
ROOT_URL: string;
|
||||||
|
WEB_AUTH_RP_ID: string;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { Account, Currency, DataSource } from '@prisma/client';
|
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
|
||||||
|
|
||||||
import { OrderType } from '../../models/order-type';
|
import { OrderType } from '../../models/order-type';
|
||||||
|
|
||||||
@ -41,6 +41,7 @@ export interface IOrder {
|
|||||||
id?: string;
|
id?: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
symbolProfile: SymbolProfile;
|
||||||
type: OrderType;
|
type: OrderType;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
}
|
}
|
||||||
|
@ -45,6 +45,13 @@ const routes: Routes = [
|
|||||||
(m) => m.PricingPageModule
|
(m) => m.PricingPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/register/register-page.module').then(
|
||||||
|
(m) => m.RegisterPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'resources',
|
path: 'resources',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -55,7 +62,9 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'start',
|
path: 'start',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
|
import('./pages/landing/landing-page.module').then(
|
||||||
|
(m) => m.LandingPageModule
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'tools',
|
path: 'tools',
|
||||||
@ -83,6 +92,13 @@ const routes: Routes = [
|
|||||||
(m) => m.TransactionsPageModule
|
(m) => m.TransactionsPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'webauthn',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/webauthn/webauthn-page.module').then(
|
||||||
|
(m) => m.WebauthnPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'zen',
|
path: 'zen',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -12,13 +12,15 @@
|
|||||||
<div *ngIf="canCreateAccount" class="container create-account-container">
|
<div *ngIf="canCreateAccount" class="container create-account-container">
|
||||||
<div class="row mb-5">
|
<div class="row mb-5">
|
||||||
<div class="col-md-6 offset-md-3">
|
<div class="col-md-6 offset-md-3">
|
||||||
<div
|
<a [routerLink]="['/']">
|
||||||
class="create-account-box p-2 text-center"
|
<mat-card
|
||||||
(click)="onCreateAccount()"
|
class="create-account-box p-2 text-center"
|
||||||
|
(click)="onCreateAccount()"
|
||||||
|
>
|
||||||
|
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
||||||
|
<button mat-button color="primary" i18n>Create Account</button>
|
||||||
|
</mat-card></a
|
||||||
>
|
>
|
||||||
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
|
||||||
<button mat-button color="primary" i18n>Create Account</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,8 +5,6 @@
|
|||||||
padding: 5rem 0;
|
padding: 5rem 0;
|
||||||
|
|
||||||
.create-account-box {
|
.create-account-box {
|
||||||
border: 1px solid rgba(var(--palette-primary-500), 1);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
|
|
||||||
|
@ -68,17 +68,12 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
if (state?.user) {
|
this.user = state.user;
|
||||||
this.user = state.user;
|
|
||||||
|
|
||||||
this.canCreateAccount = hasPermission(
|
this.canCreateAccount = hasPermission(
|
||||||
this.user.permissions,
|
this.user?.permissions,
|
||||||
permissions.createUserAccount
|
permissions.createUserAccount
|
||||||
);
|
);
|
||||||
} else if (!this.tokenStorageService.getToken()) {
|
|
||||||
// User has not been logged in
|
|
||||||
this.user = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
@ -86,7 +81,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
public onCreateAccount() {
|
public onCreateAccount() {
|
||||||
this.tokenStorageService.signOut();
|
this.tokenStorageService.signOut();
|
||||||
window.location.reload();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
|
@ -2,6 +2,7 @@ import { Platform } from '@angular/cdk/platform';
|
|||||||
import { HttpClientModule } from '@angular/common/http';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import {
|
import {
|
||||||
DateAdapter,
|
DateAdapter,
|
||||||
MAT_DATE_FORMATS,
|
MAT_DATE_FORMATS,
|
||||||
@ -34,6 +35,7 @@ import { LanguageService } from './core/language.service';
|
|||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
MarkdownModule.forRoot(),
|
MarkdownModule.forRoot(),
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
MaterialCssVarsModule.forRoot({
|
MaterialCssVarsModule.forRoot({
|
||||||
darkThemeClass: 'is-dark-theme',
|
darkThemeClass: 'is-dark-theme',
|
||||||
isAutoContrast: true,
|
isAutoContrast: true,
|
||||||
|
@ -3,6 +3,11 @@
|
|||||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
{{ element.name }}
|
{{ element.name }}
|
||||||
|
<span
|
||||||
|
*ngIf="element.isDefault"
|
||||||
|
class="d-lg-inline-block d-none text-muted"
|
||||||
|
>(Default)</span
|
||||||
|
>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@ -49,8 +54,10 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="transactions">
|
<ng-container matColumnDef="transactions">
|
||||||
<th *matHeaderCellDef i18n mat-header-cell>Transactions</th>
|
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
|
||||||
<td *matCellDef="let element" mat-cell>
|
Transactions
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="text-right" mat-cell>
|
||||||
{{ element.Order?.length }}
|
{{ element.Order?.length }}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
@ -8,9 +8,11 @@
|
|||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="
|
[ngClass]="{
|
||||||
currentRoute === 'home' || currentRoute === 'zen' ? 'primary' : null
|
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
|
||||||
"
|
'text-decoration-underline':
|
||||||
|
currentRoute === 'home' || currentRoute === 'zen'
|
||||||
|
}"
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>Overview</a
|
>Overview</a
|
||||||
>
|
>
|
||||||
@ -19,13 +21,16 @@
|
|||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="
|
[ngClass]="{
|
||||||
currentRoute === 'analysis' ||
|
'font-weight-bold':
|
||||||
currentRoute === 'report' ||
|
currentRoute === 'analysis' ||
|
||||||
currentRoute === 'tools'
|
currentRoute === 'report' ||
|
||||||
? 'primary'
|
currentRoute === 'tools',
|
||||||
: null
|
'text-decoration-underline':
|
||||||
"
|
currentRoute === 'analysis' ||
|
||||||
|
currentRoute === 'report' ||
|
||||||
|
currentRoute === 'tools'
|
||||||
|
}"
|
||||||
[routerLink]="['/tools']"
|
[routerLink]="['/tools']"
|
||||||
>Tools</a
|
>Tools</a
|
||||||
>
|
>
|
||||||
@ -33,7 +38,10 @@
|
|||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'transactions' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'transactions',
|
||||||
|
'text-decoration-underline': currentRoute === 'transactions'
|
||||||
|
}"
|
||||||
[routerLink]="['/transactions']"
|
[routerLink]="['/transactions']"
|
||||||
>Transactions</a
|
>Transactions</a
|
||||||
>
|
>
|
||||||
@ -41,7 +49,10 @@
|
|||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'accounts' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'accounts',
|
||||||
|
'text-decoration-underline': currentRoute === 'accounts'
|
||||||
|
}"
|
||||||
[routerLink]="['/accounts']"
|
[routerLink]="['/accounts']"
|
||||||
>Accounts</a
|
>Accounts</a
|
||||||
>
|
>
|
||||||
@ -50,7 +61,10 @@
|
|||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'admin' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'admin',
|
||||||
|
'text-decoration-underline': currentRoute === 'admin'
|
||||||
|
}"
|
||||||
[routerLink]="['/admin']"
|
[routerLink]="['/admin']"
|
||||||
>Admin Control</a
|
>Admin Control</a
|
||||||
>
|
>
|
||||||
@ -58,7 +72,10 @@
|
|||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'resources' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'resources',
|
||||||
|
'text-decoration-underline': currentRoute === 'resources'
|
||||||
|
}"
|
||||||
[routerLink]="['/resources']"
|
[routerLink]="['/resources']"
|
||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
@ -67,7 +84,10 @@
|
|||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'pricing' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'pricing',
|
||||||
|
'text-decoration-underline': currentRoute === 'pricing'
|
||||||
|
}"
|
||||||
[routerLink]="['/pricing']"
|
[routerLink]="['/pricing']"
|
||||||
>Pricing</a
|
>Pricing</a
|
||||||
>
|
>
|
||||||
@ -75,7 +95,10 @@
|
|||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'about' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'about',
|
||||||
|
'text-decoration-underline': currentRoute === 'about'
|
||||||
|
}"
|
||||||
[routerLink]="['/about']"
|
[routerLink]="['/about']"
|
||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
@ -226,28 +249,44 @@
|
|||||||
<gf-logo></gf-logo>
|
<gf-logo></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
|
||||||
*ngIf="hasPermissionForSubscription"
|
|
||||||
i18n
|
|
||||||
mat-flat-button
|
|
||||||
[color]="currentRoute === 'pricing' ? 'primary' : null"
|
|
||||||
[routerLink]="['/pricing']"
|
|
||||||
>Pricing</a
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'about' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'about',
|
||||||
|
'text-decoration-underline': currentRoute === 'about'
|
||||||
|
}"
|
||||||
[routerLink]="['/about']"
|
[routerLink]="['/about']"
|
||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
*ngIf="hasPermissionForSubscription"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'pricing',
|
||||||
|
'text-decoration-underline': currentRoute === 'pricing'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/pricing']"
|
||||||
|
>Pricing</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="d-none d-sm-block mx-1 no-min-width px-1"
|
||||||
href="https://github.com/ghostfolio/ghostfolio"
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
>GitHub</a
|
><ion-icon name="logo-github"></ion-icon
|
||||||
>
|
></a>
|
||||||
<button i18n mat-flat-button (click)="openLoginDialog()">Sign in</button>
|
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
class="d-none d-sm-block"
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[routerLink]="['/register']"
|
||||||
|
>Get Started
|
||||||
|
</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
@ -8,10 +8,15 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
|
import {
|
||||||
|
STAY_SIGNED_IN,
|
||||||
|
SettingsStorageService
|
||||||
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
@ -42,6 +47,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
private impersonationStorageService: ImpersonationStorageService,
|
private impersonationStorageService: ImpersonationStorageService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
private settingsStorageService: SettingsStorageService,
|
||||||
private tokenStorageService: TokenStorageService
|
private tokenStorageService: TokenStorageService
|
||||||
) {
|
) {
|
||||||
this.impersonationStorageService
|
this.impersonationStorageService
|
||||||
@ -87,7 +93,8 @@ export class HeaderComponent implements OnChanges {
|
|||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
data: {
|
data: {
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin
|
hasPermissionToUseSocialLogin: this.hasPermissionForSocialLogin,
|
||||||
|
title: 'Sign in'
|
||||||
},
|
},
|
||||||
width: '30rem'
|
width: '30rem'
|
||||||
});
|
});
|
||||||
@ -112,7 +119,10 @@ export class HeaderComponent implements OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setToken(aToken: string) {
|
public setToken(aToken: string) {
|
||||||
this.tokenStorageService.saveToken(aToken);
|
this.tokenStorageService.saveToken(
|
||||||
|
aToken,
|
||||||
|
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
||||||
|
);
|
||||||
|
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||||
|
|
||||||
import { GfLogoModule } from '../logo/logo.module';
|
import { GfLogoModule } from '../logo/logo.module';
|
||||||
import { HeaderComponent } from './header.component';
|
import { HeaderComponent } from './header.component';
|
||||||
|
@ -0,0 +1,34 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
||||||
|
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import {
|
||||||
|
STAY_SIGNED_IN,
|
||||||
|
SettingsStorageService
|
||||||
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-login-with-access-token-dialog',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
styleUrls: ['./login-with-access-token-dialog.scss'],
|
||||||
|
templateUrl: 'login-with-access-token-dialog.html'
|
||||||
|
})
|
||||||
|
export class LoginWithAccessTokenDialog {
|
||||||
|
public constructor(
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: any,
|
||||||
|
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
|
||||||
|
private settingsStorageService: SettingsStorageService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {}
|
||||||
|
|
||||||
|
public onChangeStaySignedIn(aValue: MatCheckboxChange) {
|
||||||
|
this.settingsStorageService.setSetting(
|
||||||
|
STAY_SIGNED_IN,
|
||||||
|
aValue.checked?.toString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public onClose() {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,9 @@
|
|||||||
<h1 mat-dialog-title i18n>Sign in</h1>
|
<gf-dialog-header
|
||||||
|
mat-dialog-title
|
||||||
|
[title]="data.title"
|
||||||
|
(closeButtonClicked)="onClose()"
|
||||||
|
></gf-dialog-header>
|
||||||
|
|
||||||
<div mat-dialog-content>
|
<div mat-dialog-content>
|
||||||
<div>
|
<div>
|
||||||
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
|
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
|
||||||
@ -21,15 +26,21 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="float-right" mat-dialog-actions>
|
<div mat-dialog-actions>
|
||||||
<button i18n mat-flat-button [mat-dialog-close]="undefined">Cancel</button>
|
<div class="flex-grow-1">
|
||||||
<button
|
<mat-checkbox i18n (change)="onChangeStaySignedIn($event)"
|
||||||
color="primary"
|
>Stay signed in</mat-checkbox
|
||||||
i18n
|
>
|
||||||
mat-flat-button
|
</div>
|
||||||
[disabled]="!data.accessToken"
|
<div>
|
||||||
[mat-dialog-close]="data"
|
<button
|
||||||
>
|
color="primary"
|
||||||
Sign in
|
i18n
|
||||||
</button>
|
mat-flat-button
|
||||||
|
[disabled]="!data.accessToken"
|
||||||
|
[mat-dialog-close]="data"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
@ -3,10 +3,12 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
|
||||||
|
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
|
||||||
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';
|
import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@ -15,7 +17,9 @@ import { LoginWithAccessTokenDialog } from './login-with-access-token-dialog.com
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
GfDialogHeaderModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatCheckboxModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
@ -0,0 +1,15 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
textarea.mat-input-element.cdk-textarea-autosize {
|
||||||
|
box-sizing: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-checkbox {
|
||||||
|
::ng-deep {
|
||||||
|
label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,158 +0,0 @@
|
|||||||
// import 'chartjs-chart-timeline';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
Input,
|
|
||||||
OnChanges,
|
|
||||||
OnInit
|
|
||||||
} from '@angular/core';
|
|
||||||
import { PortfolioItem } from '@ghostfolio/common/interfaces';
|
|
||||||
import { endOfDay, parseISO, startOfDay } from 'date-fns';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'gf-portfolio-positions-chart',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
templateUrl: './portfolio-positions-chart.component.html',
|
|
||||||
styleUrls: ['./portfolio-positions-chart.component.scss']
|
|
||||||
})
|
|
||||||
export class PortfolioPositionsChartComponent implements OnChanges, OnInit {
|
|
||||||
@Input() portfolioItems: PortfolioItem[];
|
|
||||||
|
|
||||||
// @ViewChild('timelineCanvas') timeline;
|
|
||||||
|
|
||||||
public isLoading = true;
|
|
||||||
|
|
||||||
public constructor() {}
|
|
||||||
|
|
||||||
public ngOnInit() {}
|
|
||||||
|
|
||||||
public ngOnChanges() {
|
|
||||||
if (this.portfolioItems) {
|
|
||||||
this.initialize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private initialize() {
|
|
||||||
this.isLoading = true;
|
|
||||||
|
|
||||||
let datasets = [];
|
|
||||||
const fromToPosition = {};
|
|
||||||
|
|
||||||
this.portfolioItems.forEach((positionsByDay) => {
|
|
||||||
Object.keys(positionsByDay.positions).forEach((symbol) => {
|
|
||||||
if (fromToPosition[symbol]) {
|
|
||||||
fromToPosition[symbol].push({
|
|
||||||
date: positionsByDay.date,
|
|
||||||
quantity: positionsByDay.positions[symbol].quantity
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
fromToPosition[symbol] = [
|
|
||||||
{
|
|
||||||
date: positionsByDay.date,
|
|
||||||
quantity: positionsByDay.positions[symbol].quantity
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(fromToPosition).forEach((symbol) => {
|
|
||||||
let currentDate = null;
|
|
||||||
let currentQuantity = null;
|
|
||||||
let data = [];
|
|
||||||
let hasStock = false;
|
|
||||||
|
|
||||||
fromToPosition[symbol].forEach((x, index) => {
|
|
||||||
if (x.quantity > 0 && index === 0) {
|
|
||||||
currentDate = x.date;
|
|
||||||
hasStock = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (x.quantity === 0 || index === fromToPosition[symbol].length - 1) {
|
|
||||||
if (hasStock) {
|
|
||||||
data.push([
|
|
||||||
startOfDay(parseISO(currentDate)),
|
|
||||||
endOfDay(parseISO(x.date)),
|
|
||||||
currentQuantity
|
|
||||||
]);
|
|
||||||
hasStock = false;
|
|
||||||
} else {
|
|
||||||
// Do nothing
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (hasStock) {
|
|
||||||
// Do nothing
|
|
||||||
} else {
|
|
||||||
currentDate = x.date;
|
|
||||||
hasStock = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentQuantity = x.quantity;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.length === 0) {
|
|
||||||
// Fill data for today
|
|
||||||
data.push([
|
|
||||||
startOfDay(new Date()),
|
|
||||||
endOfDay(new Date()),
|
|
||||||
currentQuantity
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
datasets.push({ data, symbol });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by date
|
|
||||||
datasets = datasets.sort((a: any, b: any) => {
|
|
||||||
return a.data[0][0].getTime() - b.data[0][0].getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
/*new Chart(this.timeline.nativeElement, {
|
|
||||||
type: 'timeline',
|
|
||||||
options: {
|
|
||||||
elements: {
|
|
||||||
colorFunction: (text, data, dataset, index) => {
|
|
||||||
return `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`;
|
|
||||||
},
|
|
||||||
showText: false,
|
|
||||||
textPadding: 4
|
|
||||||
},
|
|
||||||
maintainAspectRatio: true,
|
|
||||||
responsive: true,
|
|
||||||
scales: {
|
|
||||||
xAxes: [
|
|
||||||
{
|
|
||||||
gridLines: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
position: 'top',
|
|
||||||
time: {
|
|
||||||
unit: 'year'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
yAxes: [
|
|
||||||
{
|
|
||||||
gridLines: {
|
|
||||||
display: false
|
|
||||||
},
|
|
||||||
ticks: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
datasets,
|
|
||||||
labels: datasets.map((dataset) => {
|
|
||||||
return dataset.symbol;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
});*/
|
|
||||||
|
|
||||||
this.isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
@ -29,6 +29,7 @@ export class PortfolioProportionChartComponent
|
|||||||
@Input() isInPercent: boolean;
|
@Input() isInPercent: boolean;
|
||||||
@Input() key: string;
|
@Input() key: string;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
|
@Input() maxItems?: number;
|
||||||
@Input() positions: {
|
@Input() positions: {
|
||||||
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
|
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
|
||||||
};
|
};
|
||||||
@ -90,12 +91,40 @@ export class PortfolioProportionChartComponent
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const chartDataSorted = Object.entries(chartData)
|
let chartDataSorted = Object.entries(chartData)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return a[1].value - b[1].value;
|
return a[1].value - b[1].value;
|
||||||
})
|
})
|
||||||
.reverse();
|
.reverse();
|
||||||
|
|
||||||
|
if (this.maxItems && chartDataSorted.length > this.maxItems) {
|
||||||
|
// Add surplus items to unknown group
|
||||||
|
const rest = chartDataSorted.splice(
|
||||||
|
this.maxItems,
|
||||||
|
chartDataSorted.length - 1
|
||||||
|
);
|
||||||
|
|
||||||
|
let unknownItem = chartDataSorted.find((charDataItem) => {
|
||||||
|
return charDataItem[0] === UNKNOWN_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!unknownItem) {
|
||||||
|
const index = chartDataSorted.push([UNKNOWN_KEY, { value: 0 }]);
|
||||||
|
unknownItem = chartDataSorted[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
rest.forEach((restItem) => {
|
||||||
|
unknownItem[1] = { value: unknownItem[1].value + restItem[1].value };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort data again
|
||||||
|
chartDataSorted = chartDataSorted
|
||||||
|
.sort((a, b) => {
|
||||||
|
return a[1].value - b[1].value;
|
||||||
|
})
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
chartDataSorted.forEach(([symbol, item], index) => {
|
chartDataSorted.forEach(([symbol, item], index) => {
|
||||||
if (this.colorMap[symbol]) {
|
if (this.colorMap[symbol]) {
|
||||||
// Reuse color
|
// Reuse color
|
||||||
|
@ -40,6 +40,16 @@
|
|||||||
mat-table
|
mat-table
|
||||||
[dataSource]="dataSource"
|
[dataSource]="dataSource"
|
||||||
>
|
>
|
||||||
|
<ng-container matColumnDef="count">
|
||||||
|
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>#</th>
|
||||||
|
<td
|
||||||
|
*matCellDef="let element; let i = index"
|
||||||
|
class="px-1 text-right"
|
||||||
|
mat-cell
|
||||||
|
>
|
||||||
|
{{ dataSource.data.length - i }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
<ng-container matColumnDef="date">
|
<ng-container matColumnDef="date">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
|
@ -133,6 +133,7 @@ export class TransactionsTableComponent
|
|||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
this.displayedColumns = [
|
this.displayedColumns = [
|
||||||
|
'count',
|
||||||
'date',
|
'date',
|
||||||
'type',
|
'type',
|
||||||
'symbol',
|
'symbol',
|
||||||
|
@ -1,12 +1,10 @@
|
|||||||
<ngx-skeleton-loader
|
<ngx-skeleton-loader
|
||||||
*ngIf="isLoading"
|
*ngIf="isLoading"
|
||||||
animation="pulse"
|
animation="pulse"
|
||||||
|
class="h-100"
|
||||||
[theme]="{
|
[theme]="{
|
||||||
height: '30rem',
|
|
||||||
width: '100%'
|
width: '100%'
|
||||||
}"
|
}"
|
||||||
></ngx-skeleton-loader>
|
></ngx-skeleton-loader>
|
||||||
<canvas
|
|
||||||
#timelineCanvas
|
<div class="align-items-center d-flex h-100 w-100" id="svgMap"></div>
|
||||||
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
|
|
||||||
></canvas>
|
|
@ -0,0 +1,32 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
::ng-deep {
|
||||||
|
.loader {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svgMap-map-wrapper {
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.svgMap-country {
|
||||||
|
stroke: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svgMap-map-controls-wrapper {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
::ng-deep {
|
||||||
|
.svgMap-map-wrapper {
|
||||||
|
.svgMap-country {
|
||||||
|
stroke: #414141;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Currency } from '@prisma/client';
|
||||||
|
import svgMap from 'svgmap';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-world-map-chart',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
templateUrl: './world-map-chart.component.html',
|
||||||
|
styleUrls: ['./world-map-chart.component.scss']
|
||||||
|
})
|
||||||
|
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||||
|
@Input() baseCurrency: Currency;
|
||||||
|
@Input() countries: { [code: string]: { name: string; value: number } };
|
||||||
|
|
||||||
|
public isLoading = true;
|
||||||
|
public svgMapElement;
|
||||||
|
|
||||||
|
public constructor(private changeDetectorRef: ChangeDetectorRef) {}
|
||||||
|
|
||||||
|
public ngOnInit() {}
|
||||||
|
|
||||||
|
public ngOnChanges() {
|
||||||
|
if (this.countries) {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
this.destroySvgMap();
|
||||||
|
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.destroySvgMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialize() {
|
||||||
|
this.svgMapElement = new svgMap({
|
||||||
|
colorMax: '#22bdb9',
|
||||||
|
colorMin: '#c3f1f0',
|
||||||
|
colorNoData: 'transparent',
|
||||||
|
data: {
|
||||||
|
applyData: 'value',
|
||||||
|
data: {
|
||||||
|
value: {
|
||||||
|
format: `{0} ${this.baseCurrency}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
values: this.countries
|
||||||
|
},
|
||||||
|
hideFlag: true,
|
||||||
|
minZoom: 1.06,
|
||||||
|
maxZoom: 1.06,
|
||||||
|
targetElementID: 'svgMap'
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroySvgMap() {
|
||||||
|
this.svgMapElement?.mapWrapper?.remove();
|
||||||
|
this.svgMapElement?.tooltip?.remove();
|
||||||
|
|
||||||
|
this.svgMapElement = null;
|
||||||
|
}
|
||||||
|
}
|
@ -2,12 +2,12 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { PortfolioPositionsChartComponent } from './portfolio-positions-chart.component';
|
import { WorldMapChartComponent } from './world-map-chart.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [PortfolioPositionsChartComponent],
|
declarations: [WorldMapChartComponent],
|
||||||
exports: [PortfolioPositionsChartComponent],
|
exports: [WorldMapChartComponent],
|
||||||
imports: [CommonModule, NgxSkeletonLoaderModule],
|
imports: [CommonModule, NgxSkeletonLoaderModule],
|
||||||
providers: []
|
providers: []
|
||||||
})
|
})
|
||||||
export class PortfolioPositionsChartModule {}
|
export class GfWorldMapChartModule {}
|
@ -14,7 +14,12 @@ import { UserService } from '../services/user/user.service';
|
|||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
private static PUBLIC_PAGE_ROUTES = ['/about', '/pricing', '/resources'];
|
private static PUBLIC_PAGE_ROUTES = [
|
||||||
|
'/about',
|
||||||
|
'/pricing',
|
||||||
|
'/register',
|
||||||
|
'/resources'
|
||||||
|
];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
@ -2,12 +2,10 @@ import {
|
|||||||
HTTP_INTERCEPTORS,
|
HTTP_INTERCEPTORS,
|
||||||
HttpErrorResponse,
|
HttpErrorResponse,
|
||||||
HttpEvent,
|
HttpEvent,
|
||||||
HttpResponse
|
|
||||||
} from '@angular/common/http';
|
|
||||||
import {
|
|
||||||
HttpHandler,
|
HttpHandler,
|
||||||
HttpInterceptor,
|
HttpInterceptor,
|
||||||
HttpRequest
|
HttpRequest,
|
||||||
|
HttpResponse
|
||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
@ -16,6 +14,7 @@ import {
|
|||||||
TextOnlySnackBar
|
TextOnlySnackBar
|
||||||
} from '@angular/material/snack-bar';
|
} from '@angular/material/snack-bar';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
import { Observable, throwError } from 'rxjs';
|
import { Observable, throwError } from 'rxjs';
|
||||||
import { catchError, tap } from 'rxjs/operators';
|
import { catchError, tap } from 'rxjs/operators';
|
||||||
@ -29,7 +28,8 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private tokenStorageService: TokenStorageService,
|
private tokenStorageService: TokenStorageService,
|
||||||
private snackBar: MatSnackBar
|
private snackBar: MatSnackBar,
|
||||||
|
private webAuthnService: WebAuthnService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public intercept(
|
public intercept(
|
||||||
@ -78,7 +78,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (error.status === StatusCodes.UNAUTHORIZED) {
|
} else if (error.status === StatusCodes.UNAUTHORIZED) {
|
||||||
this.tokenStorageService.signOut();
|
if (this.webAuthnService.isEnabled()) {
|
||||||
|
this.router.navigate(['/webauthn']);
|
||||||
|
} else {
|
||||||
|
this.tokenStorageService.signOut();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return throwError('');
|
return throwError('');
|
||||||
|
@ -1,12 +1,23 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import {
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
ViewChild
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
MatSlideToggle,
|
||||||
|
MatSlideToggleChange
|
||||||
|
} from '@angular/material/slide-toggle';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
import { Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-account-page',
|
selector: 'gf-account-page',
|
||||||
@ -14,6 +25,9 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
styleUrls: ['./account-page.scss']
|
styleUrls: ['./account-page.scss']
|
||||||
})
|
})
|
||||||
export class AccountPageComponent implements OnDestroy, OnInit {
|
export class AccountPageComponent implements OnDestroy, OnInit {
|
||||||
|
@ViewChild('toggleSignInWithFingerprintEnabledElement')
|
||||||
|
signInWithFingerprintElement: MatSlideToggle;
|
||||||
|
|
||||||
public accesses: Access[];
|
public accesses: Access[];
|
||||||
public baseCurrency: Currency;
|
public baseCurrency: Currency;
|
||||||
public currencies: Currency[] = [];
|
public currencies: Currency[] = [];
|
||||||
@ -29,7 +43,8 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private userService: UserService
|
private userService: UserService,
|
||||||
|
public webAuthnService: WebAuthnService
|
||||||
) {
|
) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchInfo()
|
.fetchInfo()
|
||||||
@ -84,11 +99,57 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onSignInWithFingerprintChange(aEvent: MatSlideToggleChange) {
|
||||||
|
if (aEvent.checked) {
|
||||||
|
this.registerDevice();
|
||||||
|
} else {
|
||||||
|
const confirmation = confirm(
|
||||||
|
'Do you really want to remove this sign in method?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
this.deregisterDevice();
|
||||||
|
} else {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deregisterDevice() {
|
||||||
|
this.webAuthnService
|
||||||
|
.deregister()
|
||||||
|
.pipe(
|
||||||
|
catchError(() => {
|
||||||
|
this.update();
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerDevice() {
|
||||||
|
this.webAuthnService
|
||||||
|
.register()
|
||||||
|
.pipe(
|
||||||
|
catchError(() => {
|
||||||
|
this.update();
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private update() {
|
private update() {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchAccesses()
|
.fetchAccesses()
|
||||||
@ -96,6 +157,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
.subscribe((response) => {
|
.subscribe((response) => {
|
||||||
this.accesses = response;
|
this.accesses = response;
|
||||||
|
|
||||||
|
if (this.signInWithFingerprintElement) {
|
||||||
|
this.signInWithFingerprintElement.checked =
|
||||||
|
this.webAuthnService.isEnabled() ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -66,6 +66,16 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="align-items-center d-flex mt-4 py-1">
|
||||||
|
<div class="w-50" i18n>Sign in with fingerprint</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<mat-slide-toggle
|
||||||
|
#toggleSignInWithFingerprintEnabledElement
|
||||||
|
color="primary"
|
||||||
|
(change)="onSignInWithFingerprintChange($event)"
|
||||||
|
></mat-slide-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,8 +3,11 @@ import { NgModule } from '@angular/core';
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
||||||
|
|
||||||
import { AccountPageRoutingModule } from './account-page-routing.module';
|
import { AccountPageRoutingModule } from './account-page-routing.module';
|
||||||
@ -20,8 +23,11 @@ import { AccountPageComponent } from './account-page.component';
|
|||||||
GfPortfolioAccessTableModule,
|
GfPortfolioAccessTableModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
|
MatDialogModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
|
MatSlideToggleModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule
|
||||||
],
|
],
|
||||||
providers: []
|
providers: []
|
||||||
|
@ -5,7 +5,12 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
|||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||||
import { AdminData, User } from '@ghostfolio/common/interfaces';
|
import { AdminData, User } from '@ghostfolio/common/interfaces';
|
||||||
import { formatDistanceToNowStrict, isValid, parseISO } from 'date-fns';
|
import {
|
||||||
|
differenceInSeconds,
|
||||||
|
formatDistanceToNowStrict,
|
||||||
|
isValid,
|
||||||
|
parseISO
|
||||||
|
} from 'date-fns';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -80,7 +85,10 @@ export class AdminPageComponent implements OnInit {
|
|||||||
addSuffix: true
|
addSuffix: true
|
||||||
});
|
});
|
||||||
|
|
||||||
return distanceString === '0 seconds ago' ? 'just now' : distanceString;
|
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
|
||||||
|
60
|
||||||
|
? 'just now'
|
||||||
|
: distanceString;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
|
@ -73,18 +73,18 @@
|
|||||||
<table class="gf-table">
|
<table class="gf-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="mat-header-row">
|
<tr class="mat-header-row">
|
||||||
<th class="mat-header-cell px-1 py-2 text-center" i18n>#</th>
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-center" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
Registration Date
|
Registration Date
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-center" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
Accounts
|
Accounts
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-center" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
Transactions
|
Transactions
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-center" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
Engagement
|
Engagement
|
||||||
</th>
|
</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import {
|
||||||
|
STAY_SIGNED_IN,
|
||||||
|
SettingsStorageService
|
||||||
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@ -14,6 +18,7 @@ export class AuthPageComponent implements OnInit {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
private settingsStorageService: SettingsStorageService,
|
||||||
private tokenStorageService: TokenStorageService
|
private tokenStorageService: TokenStorageService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -23,7 +28,10 @@ export class AuthPageComponent implements OnInit {
|
|||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.route.params.subscribe((params) => {
|
this.route.params.subscribe((params) => {
|
||||||
const jwt = params['jwt'];
|
const jwt = params['jwt'];
|
||||||
this.tokenStorageService.saveToken(jwt);
|
this.tokenStorageService.saveToken(
|
||||||
|
jwt,
|
||||||
|
this.settingsStorageService.getSetting(STAY_SIGNED_IN) === 'true'
|
||||||
|
);
|
||||||
|
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
});
|
});
|
||||||
|
@ -2,14 +2,14 @@ import { NgModule } from '@angular/core';
|
|||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
import { LoginPageComponent } from './login-page.component';
|
import { LandingPageComponent } from './landing-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: LoginPageComponent, canActivate: [AuthGuard] }
|
{ path: '', component: LandingPageComponent, canActivate: [AuthGuard] }
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [RouterModule.forChild(routes)],
|
imports: [RouterModule.forChild(routes)],
|
||||||
exports: [RouterModule]
|
exports: [RouterModule]
|
||||||
})
|
})
|
||||||
export class LoginPageRoutingModule {}
|
export class LandingPageRoutingModule {}
|
@ -1,21 +1,18 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
|
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
|
||||||
|
|
||||||
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-login-page',
|
selector: 'gf-landing-page',
|
||||||
templateUrl: './login-page.html',
|
templateUrl: './landing-page.html',
|
||||||
styleUrls: ['./login-page.scss']
|
styleUrls: ['./landing-page.scss']
|
||||||
})
|
})
|
||||||
export class LoginPageComponent implements OnDestroy, OnInit {
|
export class LandingPageComponent implements OnDestroy, OnInit {
|
||||||
public currentYear = format(new Date(), 'yyyy');
|
public currentYear = format(new Date(), 'yyyy');
|
||||||
public demoAuthToken: string;
|
public demoAuthToken: string;
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
@ -28,7 +25,6 @@ export class LoginPageComponent implements OnDestroy, OnInit {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private dialog: MatDialog,
|
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private tokenStorageService: TokenStorageService
|
private tokenStorageService: TokenStorageService
|
||||||
) {}
|
) {}
|
||||||
@ -46,15 +42,6 @@ export class LoginPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createAccount() {
|
|
||||||
this.dataService
|
|
||||||
.postUser()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(({ accessToken, authToken }) => {
|
|
||||||
this.openShowAccessTokenDialog(accessToken, authToken);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public initializeLineChart() {
|
public initializeLineChart() {
|
||||||
this.historicalDataItems = [
|
this.historicalDataItems = [
|
||||||
{
|
{
|
||||||
@ -268,30 +255,8 @@ export class LoginPageComponent implements OnDestroy, OnInit {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public openShowAccessTokenDialog(
|
|
||||||
accessToken: string,
|
|
||||||
authToken: string
|
|
||||||
): void {
|
|
||||||
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
|
|
||||||
data: {
|
|
||||||
accessToken,
|
|
||||||
authToken
|
|
||||||
},
|
|
||||||
disableClose: true,
|
|
||||||
width: '30rem'
|
|
||||||
});
|
|
||||||
|
|
||||||
dialogRef.afterClosed().subscribe((data) => {
|
|
||||||
if (data?.authToken) {
|
|
||||||
this.tokenStorageService.saveToken(authToken);
|
|
||||||
|
|
||||||
this.router.navigate(['/']);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public setToken(aToken: string) {
|
public setToken(aToken: string) {
|
||||||
this.tokenStorageService.saveToken(aToken);
|
this.tokenStorageService.saveToken(aToken, true);
|
||||||
|
|
||||||
this.router.navigate(['/']);
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
@ -13,16 +13,16 @@
|
|||||||
class="align-items-center col d-flex justify-content-center position-relative"
|
class="align-items-center col d-flex justify-content-center position-relative"
|
||||||
>
|
>
|
||||||
<div class="py-5 text-center">
|
<div class="py-5 text-center">
|
||||||
<button
|
<a
|
||||||
class="d-inline-block"
|
class="d-inline-block"
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!demoAuthToken"
|
[disabled]="!demoAuthToken"
|
||||||
(click)="createAccount()"
|
[routerLink]="['/register']"
|
||||||
>
|
>
|
||||||
Create Account
|
Get Started
|
||||||
</button>
|
</a>
|
||||||
<div class="d-inline-block mx-3 text-muted" i18n>or</div>
|
<div class="d-inline-block mx-3 text-muted" i18n>or</div>
|
||||||
<button
|
<button
|
||||||
class="d-inline-block"
|
class="d-inline-block"
|
||||||
@ -135,15 +135,15 @@
|
|||||||
Join now or check out the example account
|
Join now or check out the example account
|
||||||
</p>
|
</p>
|
||||||
<div class="py-2 text-center">
|
<div class="py-2 text-center">
|
||||||
<button
|
<a
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[disabled]="!demoAuthToken"
|
[disabled]="!demoAuthToken"
|
||||||
(click)="createAccount()"
|
[routerLink]="['/register']"
|
||||||
>
|
>
|
||||||
Create Account
|
Get Started
|
||||||
</button>
|
</a>
|
||||||
<div class="d-inline-block mx-3 text-muted" i18n>or</div>
|
<div class="d-inline-block mx-3 text-muted" i18n>or</div>
|
||||||
<button
|
<button
|
||||||
class="d-inline-block"
|
class="d-inline-block"
|
25
apps/client/src/app/pages/landing/landing-page.module.ts
Normal file
25
apps/client/src/app/pages/landing/landing-page.module.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
|
||||||
|
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
|
||||||
|
|
||||||
|
import { LandingPageRoutingModule } from './landing-page-routing.module';
|
||||||
|
import { LandingPageComponent } from './landing-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [LandingPageComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfLineChartModule,
|
||||||
|
GfLogoModule,
|
||||||
|
LandingPageRoutingModule,
|
||||||
|
MatButtonModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class LandingPageModule {}
|
@ -1,14 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
|
||||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'login-with-access-token-dialog',
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
styleUrls: ['./login-with-access-token-dialog.scss'],
|
|
||||||
templateUrl: 'login-with-access-token-dialog.html'
|
|
||||||
})
|
|
||||||
export class LoginWithAccessTokenDialog {
|
|
||||||
public constructor(@Inject(MAT_DIALOG_DATA) public data: any) {}
|
|
||||||
|
|
||||||
ngOnInit() {}
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
:host {
|
|
||||||
textarea.mat-input-element.cdk-textarea-autosize {
|
|
||||||
box-sizing: content-box;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +1,9 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>Pricing Plans</h3>
|
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
|
||||||
|
Pricing Plans
|
||||||
|
</h3>
|
||||||
<mat-card class="mb-4">
|
<mat-card class="mb-4">
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<p>
|
<p>
|
||||||
@ -188,8 +190,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div *ngIf="!user" class="row">
|
<div *ngIf="!user" class="row">
|
||||||
<div class="col mt-3 text-center">
|
<div class="col mt-3 text-center">
|
||||||
<a color="primary" i18n mat-flat-button [routerLink]="['/start']">
|
<a color="primary" i18n mat-flat-button [routerLink]="['/register']">
|
||||||
Create Account
|
Get Started
|
||||||
</a>
|
</a>
|
||||||
<p class="text-muted"><small>It's free</small></p>
|
<p class="text-muted"><small>It's free</small></p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
|
import { RegisterPageComponent } from './register-page.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: '', component: RegisterPageComponent, canActivate: [AuthGuard] }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class RegisterPageRoutingModule {}
|
@ -0,0 +1,92 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-register-page',
|
||||||
|
templateUrl: './register-page.html',
|
||||||
|
styleUrls: ['./register-page.scss']
|
||||||
|
})
|
||||||
|
export class RegisterPageComponent implements OnDestroy, OnInit {
|
||||||
|
public currentYear = format(new Date(), 'yyyy');
|
||||||
|
public demoAuthToken: string;
|
||||||
|
public hasPermissionForSocialLogin: boolean;
|
||||||
|
public historicalDataItems: LineChartItem[];
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private router: Router,
|
||||||
|
private tokenStorageService: TokenStorageService
|
||||||
|
) {
|
||||||
|
this.tokenStorageService.signOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.dataService
|
||||||
|
.fetchInfo()
|
||||||
|
.subscribe(({ demoAuthToken, globalPermissions }) => {
|
||||||
|
this.demoAuthToken = demoAuthToken;
|
||||||
|
this.hasPermissionForSocialLogin = hasPermission(
|
||||||
|
globalPermissions,
|
||||||
|
permissions.enableSocialLogin
|
||||||
|
);
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAccount() {
|
||||||
|
this.dataService
|
||||||
|
.postUser()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ accessToken, authToken }) => {
|
||||||
|
this.openShowAccessTokenDialog(accessToken, authToken);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public openShowAccessTokenDialog(
|
||||||
|
accessToken: string,
|
||||||
|
authToken: string
|
||||||
|
): void {
|
||||||
|
const dialogRef = this.dialog.open(ShowAccessTokenDialog, {
|
||||||
|
data: {
|
||||||
|
accessToken,
|
||||||
|
authToken
|
||||||
|
},
|
||||||
|
disableClose: true,
|
||||||
|
width: '30rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((data) => {
|
||||||
|
if (data?.authToken) {
|
||||||
|
this.tokenStorageService.saveToken(authToken, true);
|
||||||
|
|
||||||
|
this.router.navigate(['/']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
30
apps/client/src/app/pages/register/register-page.html
Normal file
30
apps/client/src/app/pages/register/register-page.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
|
||||||
|
Create your Ghostfolio account
|
||||||
|
</h3>
|
||||||
|
<mat-card class="mb-4">
|
||||||
|
<mat-card-content class="text-center">
|
||||||
|
<button
|
||||||
|
class="d-inline-block"
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[disabled]="!demoAuthToken"
|
||||||
|
(click)="createAccount()"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
<ng-container *ngIf="hasPermissionForSocialLogin">
|
||||||
|
<div class="my-3 text-muted" i18n>or</div>
|
||||||
|
<a color="accent" href="/api/auth/google" mat-flat-button
|
||||||
|
><ion-icon class="mr-1" name="logo-google"></ion-icon
|
||||||
|
><span i18n>Continue with Google</span></a
|
||||||
|
>
|
||||||
|
</ng-container>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -1,27 +1,27 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
|
|
||||||
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
|
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
|
||||||
|
|
||||||
import { LoginPageRoutingModule } from './login-page-routing.module';
|
import { RegisterPageRoutingModule } from './register-page-routing.module';
|
||||||
import { LoginPageComponent } from './login-page.component';
|
import { RegisterPageComponent } from './register-page.component';
|
||||||
import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module';
|
import { ShowAccessTokenDialogModule } from './show-access-token-dialog/show-access-token-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [LoginPageComponent],
|
declarations: [RegisterPageComponent],
|
||||||
exports: [],
|
exports: [],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfLineChartModule,
|
|
||||||
GfLogoModule,
|
GfLogoModule,
|
||||||
LoginPageRoutingModule,
|
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
RegisterPageRoutingModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
ShowAccessTokenDialogModule
|
ShowAccessTokenDialogModule
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class LoginPageModule {}
|
export class RegisterPageModule {}
|
@ -7,7 +7,7 @@ import {
|
|||||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'show-access-token-dialog',
|
selector: 'gf-show-access-token-dialog',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
styleUrls: ['./show-access-token-dialog.scss'],
|
styleUrls: ['./show-access-token-dialog.scss'],
|
||||||
templateUrl: 'show-access-token-dialog.html'
|
templateUrl: 'show-access-token-dialog.html'
|
@ -1,5 +1,5 @@
|
|||||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||||
import { CdkTextareaAutosize, TextFieldModule } from '@angular/cdk/text-field';
|
import { TextFieldModule } from '@angular/cdk/text-field';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
@ -3,6 +3,7 @@ import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/to
|
|||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
PortfolioItem,
|
PortfolioItem,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
@ -21,6 +22,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
public accounts: {
|
public accounts: {
|
||||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
|
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
|
||||||
};
|
};
|
||||||
|
public continents: {
|
||||||
|
[code: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
|
public countries: {
|
||||||
|
[code: string]: { name: string; value: number };
|
||||||
|
};
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public period = 'current';
|
public period = 'current';
|
||||||
public periodOptions: ToggleOption[] = [
|
public periodOptions: ToggleOption[] = [
|
||||||
@ -97,6 +104,18 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
aPeriod: string
|
aPeriod: string
|
||||||
) {
|
) {
|
||||||
this.accounts = {};
|
this.accounts = {};
|
||||||
|
this.continents = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.countries = {
|
||||||
|
[UNKNOWN_KEY]: {
|
||||||
|
name: UNKNOWN_KEY,
|
||||||
|
value: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
this.positions = {};
|
this.positions = {};
|
||||||
this.positionsArray = [];
|
this.positionsArray = [];
|
||||||
|
|
||||||
@ -122,11 +141,53 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
|||||||
aPeriod === 'original' ? original : current;
|
aPeriod === 'original' ? original : current;
|
||||||
} else {
|
} else {
|
||||||
this.accounts[account] = {
|
this.accounts[account] = {
|
||||||
value: aPeriod === 'original' ? original : current,
|
name: account,
|
||||||
name: account
|
value: aPeriod === 'original' ? original : current
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (position.countries.length > 0) {
|
||||||
|
for (const country of position.countries) {
|
||||||
|
const { code, continent, name, weight } = country;
|
||||||
|
|
||||||
|
if (this.continents[continent]?.value) {
|
||||||
|
this.continents[continent].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.continents[continent] = {
|
||||||
|
name: continent,
|
||||||
|
value:
|
||||||
|
weight *
|
||||||
|
(aPeriod === 'original'
|
||||||
|
? this.portfolioPositions[symbol].investment
|
||||||
|
: this.portfolioPositions[symbol].value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.countries[code]?.value) {
|
||||||
|
this.countries[code].value += weight * position.value;
|
||||||
|
} else {
|
||||||
|
this.countries[code] = {
|
||||||
|
name,
|
||||||
|
value:
|
||||||
|
weight *
|
||||||
|
(aPeriod === 'original'
|
||||||
|
? this.portfolioPositions[symbol].investment
|
||||||
|
: this.portfolioPositions[symbol].value)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.continents[UNKNOWN_KEY].value +=
|
||||||
|
aPeriod === 'original'
|
||||||
|
? this.portfolioPositions[symbol].investment
|
||||||
|
: this.portfolioPositions[symbol].value;
|
||||||
|
|
||||||
|
this.countries[UNKNOWN_KEY].value +=
|
||||||
|
aPeriod === 'original'
|
||||||
|
? this.portfolioPositions[symbol].investment
|
||||||
|
: this.portfolioPositions[symbol].value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,6 +75,7 @@
|
|||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[isInPercent]="true"
|
[isInPercent]="true"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[maxItems]="10"
|
||||||
[positions]="positions"
|
[positions]="positions"
|
||||||
></gf-portfolio-proportion-chart>
|
></gf-portfolio-proportion-chart>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
@ -97,6 +98,7 @@
|
|||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[isInPercent]="true"
|
[isInPercent]="true"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
|
[maxItems]="10"
|
||||||
[positions]="positions"
|
[positions]="positions"
|
||||||
></gf-portfolio-proportion-chart>
|
></gf-portfolio-proportion-chart>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
@ -146,39 +148,82 @@
|
|||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-md-6">
|
||||||
<div class="d-block d-sm-none row">
|
|
||||||
<div class="col-lg">
|
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-header class="w-100">
|
||||||
|
<mat-card-title i18n>By Continent</mat-card-title>
|
||||||
|
<gf-toggle
|
||||||
|
[defaultValue]="period"
|
||||||
|
[isLoading]="false"
|
||||||
|
[options]="periodOptions"
|
||||||
|
(change)="onChangePeriod($event.value)"
|
||||||
|
></gf-toggle>
|
||||||
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="d-flex align-items-center justify-content-center">
|
<gf-portfolio-proportion-chart
|
||||||
<div class="mr-2">
|
key="name"
|
||||||
<ion-icon
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
name="information-circle-outline"
|
[isInPercent]="false"
|
||||||
size="small"
|
[locale]="user?.settings?.locale"
|
||||||
></ion-icon>
|
[positions]="continents"
|
||||||
</div>
|
></gf-portfolio-proportion-chart>
|
||||||
<div i18n>
|
</mat-card-content>
|
||||||
You can find more charts on your desktop:
|
</mat-card>
|
||||||
<a href="https://ghostfol.io" target="_blank">Ghostfol.io</a>
|
</div>
|
||||||
</div>
|
<div class="col-md-6">
|
||||||
</div>
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-header class="w-100">
|
||||||
|
<mat-card-title i18n>By Country</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]="countries"
|
||||||
|
></gf-portfolio-proportion-chart>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-none d-sm-block row">
|
<div class="row world-map-chart">
|
||||||
|
<div class="col-lg">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-header class="w-100">
|
||||||
|
<mat-card-title i18n>Regions</mat-card-title>
|
||||||
|
<gf-toggle
|
||||||
|
[defaultValue]="period"
|
||||||
|
[isLoading]="false"
|
||||||
|
[options]="periodOptions"
|
||||||
|
(change)="onChangePeriod($event.value)"
|
||||||
|
></gf-toggle>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-world-map-chart
|
||||||
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
[countries]="countries"
|
||||||
|
></gf-world-map-chart>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<mat-card class="mb-3">
|
<mat-card class="mb-3">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title class="align-items-center d-flex" i18n
|
<mat-card-title class="align-items-center d-flex" i18n
|
||||||
>Investment</mat-card-title
|
>Timeline</mat-card-title
|
||||||
>
|
>
|
||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<!--<gf-positions-chart
|
|
||||||
[portfolioItems]="portfolioItems"
|
|
||||||
></gf-positions-chart>-->
|
|
||||||
<gf-investment-chart
|
<gf-investment-chart
|
||||||
[portfolioItems]="portfolioItems"
|
[portfolioItems]="portfolioItems"
|
||||||
></gf-investment-chart>
|
></gf-investment-chart>
|
||||||
|
@ -2,10 +2,10 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
||||||
import { PortfolioPositionsChartModule } from '@ghostfolio/client/components/portfolio-positions-chart/portfolio-positions-chart.module';
|
|
||||||
import { PortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
import { PortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||||
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
|
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
|
||||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||||
|
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||||
|
|
||||||
import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
|
import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
|
||||||
import { AnalysisPageComponent } from './analysis-page.component';
|
import { AnalysisPageComponent } from './analysis-page.component';
|
||||||
@ -19,8 +19,8 @@ import { AnalysisPageComponent } from './analysis-page.component';
|
|||||||
GfInvestmentChartModule,
|
GfInvestmentChartModule,
|
||||||
GfPositionsTableModule,
|
GfPositionsTableModule,
|
||||||
GfToggleModule,
|
GfToggleModule,
|
||||||
|
GfWorldMapChartModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
PortfolioPositionsChartModule,
|
|
||||||
PortfolioProportionChartModule
|
PortfolioProportionChartModule
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
@ -7,6 +7,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.world-map-chart {
|
||||||
|
.mat-card {
|
||||||
|
.mat-card-content {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.mat-card {
|
.mat-card {
|
||||||
.mat-card-header {
|
.mat-card-header {
|
||||||
::ng-deep {
|
::ng-deep {
|
||||||
|
@ -9,14 +9,14 @@
|
|||||||
portfolio.
|
portfolio.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-right">
|
<p class="text-right">
|
||||||
<button
|
<a
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
i18n
|
||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/tools', 'analysis']"
|
[routerLink]="['/tools', 'analysis']"
|
||||||
>
|
>
|
||||||
Open Analysis →
|
Open Analysis →
|
||||||
</button>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
@ -28,14 +28,14 @@
|
|||||||
risks in your portfolio.
|
risks in your portfolio.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-right">
|
<p class="text-right">
|
||||||
<button
|
<a
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
i18n
|
||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/tools', 'report']"
|
[routerLink]="['/tools', 'report']"
|
||||||
>
|
>
|
||||||
Open X-ray →
|
Open X-ray →
|
||||||
</button>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
27
apps/client/src/app/pages/webauthn/webauthn-page.html
Normal file
27
apps/client/src/app/pages/webauthn/webauthn-page.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<h3 class="d-flex justify-content-center" i18n>
|
||||||
|
Oops, authentication failed
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
(click)="signIn()"
|
||||||
|
class="my-4"
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
<button (click)="deregisterDevice()" i18n mat-flat-button>
|
||||||
|
Go back to Home Page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
20
apps/client/src/app/pages/webauthn/webauthn-page.module.ts
Normal file
20
apps/client/src/app/pages/webauthn/webauthn-page.module.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component';
|
||||||
|
|
||||||
|
import { WebauthnPageRoutingModule } from './webauthn-page-routing.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [WebauthnPageComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
WebauthnPageRoutingModule
|
||||||
|
],
|
||||||
|
providers: []
|
||||||
|
})
|
||||||
|
export class WebauthnPageModule {}
|
@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
export const RANGE = 'range';
|
export const RANGE = 'range';
|
||||||
|
export const STAY_SIGNED_IN = 'staySignedIn';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
@ -15,4 +16,8 @@ export class SettingsStorageService {
|
|||||||
public setSetting(aKey: string, aValue: string) {
|
public setSetting(aKey: string, aValue: string) {
|
||||||
window.localStorage.setItem(aKey, aValue);
|
window.localStorage.setItem(aKey, aValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public removeSetting(aKey: string): void {
|
||||||
|
return window.localStorage.removeItem(aKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
|
|
||||||
|
import { UserService } from './user/user.service';
|
||||||
|
|
||||||
const TOKEN_KEY = 'auth-token';
|
const TOKEN_KEY = 'auth-token';
|
||||||
|
|
||||||
@ -6,21 +9,36 @@ const TOKEN_KEY = 'auth-token';
|
|||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class TokenStorageService {
|
export class TokenStorageService {
|
||||||
public constructor() {}
|
public constructor(
|
||||||
|
private userService: UserService,
|
||||||
|
private webAuthnService: WebAuthnService
|
||||||
|
) {}
|
||||||
|
|
||||||
public getToken(): string {
|
public getToken(): string {
|
||||||
return window.localStorage.getItem(TOKEN_KEY);
|
return (
|
||||||
|
window.sessionStorage.getItem(TOKEN_KEY) ||
|
||||||
|
window.localStorage.getItem(TOKEN_KEY)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public saveToken(token: string): void {
|
public saveToken(token: string, staySignedIn = false): void {
|
||||||
window.localStorage.removeItem(TOKEN_KEY);
|
if (staySignedIn) {
|
||||||
window.localStorage.setItem(TOKEN_KEY, token);
|
window.localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
window.sessionStorage.setItem(TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
public signOut(): void {
|
public signOut(): void {
|
||||||
const utmSource = window.localStorage.getItem('utm_source');
|
const utmSource = window.localStorage.getItem('utm_source');
|
||||||
|
|
||||||
|
if (this.webAuthnService.isEnabled()) {
|
||||||
|
this.webAuthnService.deregister().subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
if (utmSource) {
|
if (utmSource) {
|
||||||
window.localStorage.setItem('utm_source', utmSource);
|
window.localStorage.setItem('utm_source', utmSource);
|
||||||
|
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;
|
||||||
|
}
|
@ -6,18 +6,22 @@
|
|||||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io</loc>
|
<loc>https://ghostfol.io</loc>
|
||||||
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
|
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/about</loc>
|
<loc>https://ghostfol.io/about</loc>
|
||||||
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
|
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/pricing</loc>
|
<loc>https://ghostfol.io/pricing</loc>
|
||||||
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
|
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://ghostfol.io/register</loc>
|
||||||
|
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://ghostfol.io/resources</loc>
|
<loc>https://ghostfol.io/resources</loc>
|
||||||
<lastmod>2021-05-14T20:24:46+00:00</lastmod>
|
<lastmod>2021-06-03T00:00:00+00:00</lastmod>
|
||||||
</url>
|
</url>
|
||||||
</urlset>
|
</urlset>
|
||||||
|
@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
@import '~angular-material-css-vars/main';
|
@import '~angular-material-css-vars/main';
|
||||||
|
|
||||||
|
@import '~svgmap/dist/svgMap';
|
||||||
|
|
||||||
$mat-css-dark-theme-selector: '.is-dark-theme';
|
$mat-css-dark-theme-selector: '.is-dark-theme';
|
||||||
$mat-css-light-theme-selector: '.is-light-theme';
|
$mat-css-light-theme-selector: '.is-light-theme';
|
||||||
|
|
||||||
@ -79,6 +81,14 @@ body {
|
|||||||
color: rgba(var(--dark-primary-text)) !important;
|
color: rgba(var(--dark-primary-text)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.svgMap-tooltip {
|
||||||
|
background: var(--dark-background);
|
||||||
|
|
||||||
|
.svgMap-tooltip-content table td span {
|
||||||
|
color: rgba(var(--light-primary-text));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,3 +163,15 @@ ngx-skeleton-loader {
|
|||||||
.no-min-width {
|
.no-min-width {
|
||||||
min-width: unset !important;
|
min-width: unset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.svgMap-tooltip {
|
||||||
|
border-bottom: none;
|
||||||
|
|
||||||
|
.svgMap-tooltip-pointer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-decoration-underline {
|
||||||
|
text-decoration: underline !important;
|
||||||
|
}
|
||||||
|
6
libs/common/src/lib/interfaces/country.interface.ts
Normal file
6
libs/common/src/lib/interfaces/country.interface.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface Country {
|
||||||
|
code: string;
|
||||||
|
continent: string;
|
||||||
|
name: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
@ -1,12 +1,15 @@
|
|||||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
|
import { Country } from './country.interface';
|
||||||
|
|
||||||
export interface PortfolioPosition {
|
export interface PortfolioPosition {
|
||||||
accounts: {
|
accounts: {
|
||||||
[name: string]: { current: number; original: number };
|
[name: string]: { current: number; original: number };
|
||||||
};
|
};
|
||||||
allocationCurrent: number;
|
allocationCurrent: number;
|
||||||
allocationInvestment: number;
|
allocationInvestment: number;
|
||||||
|
countries: Country[];
|
||||||
currency: Currency;
|
currency: Currency;
|
||||||
exchange?: string;
|
exchange?: string;
|
||||||
grossPerformance: number;
|
grossPerformance: number;
|
||||||
@ -24,4 +27,5 @@ export interface PortfolioPosition {
|
|||||||
symbol: string;
|
symbol: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
value: number;
|
||||||
}
|
}
|
||||||
|
@ -11,12 +11,14 @@ export const permissions = {
|
|||||||
createOrder: 'createOrder',
|
createOrder: 'createOrder',
|
||||||
createUserAccount: 'createUserAccount',
|
createUserAccount: 'createUserAccount',
|
||||||
deleteAccount: 'deleteAcccount',
|
deleteAccount: 'deleteAcccount',
|
||||||
|
deleteAuthDevice: 'deleteAuthDevice',
|
||||||
deleteOrder: 'deleteOrder',
|
deleteOrder: 'deleteOrder',
|
||||||
deleteUser: 'deleteUser',
|
deleteUser: 'deleteUser',
|
||||||
enableSocialLogin: 'enableSocialLogin',
|
enableSocialLogin: 'enableSocialLogin',
|
||||||
enableSubscription: 'enableSubscription',
|
enableSubscription: 'enableSubscription',
|
||||||
readForeignPortfolio: 'readForeignPortfolio',
|
readForeignPortfolio: 'readForeignPortfolio',
|
||||||
updateAccount: 'updateAccount',
|
updateAccount: 'updateAccount',
|
||||||
|
updateAuthDevice: 'updateAuthDevice',
|
||||||
updateOrder: 'updateOrder',
|
updateOrder: 'updateOrder',
|
||||||
updateUserSettings: 'updateUserSettings'
|
updateUserSettings: 'updateUserSettings'
|
||||||
};
|
};
|
||||||
@ -36,10 +38,12 @@ export function getPermissions(aRole: Role): string[] {
|
|||||||
permissions.createAccount,
|
permissions.createAccount,
|
||||||
permissions.createOrder,
|
permissions.createOrder,
|
||||||
permissions.deleteAccount,
|
permissions.deleteAccount,
|
||||||
|
permissions.deleteAuthDevice,
|
||||||
permissions.deleteOrder,
|
permissions.deleteOrder,
|
||||||
permissions.deleteUser,
|
permissions.deleteUser,
|
||||||
permissions.readForeignPortfolio,
|
permissions.readForeignPortfolio,
|
||||||
permissions.updateAccount,
|
permissions.updateAccount,
|
||||||
|
permissions.updateAuthDevice,
|
||||||
permissions.updateOrder,
|
permissions.updateOrder,
|
||||||
permissions.updateUserSettings
|
permissions.updateUserSettings
|
||||||
];
|
];
|
||||||
@ -52,8 +56,10 @@ export function getPermissions(aRole: Role): string[] {
|
|||||||
permissions.createAccount,
|
permissions.createAccount,
|
||||||
permissions.createOrder,
|
permissions.createOrder,
|
||||||
permissions.deleteAccount,
|
permissions.deleteAccount,
|
||||||
|
permissions.deleteAuthDevice,
|
||||||
permissions.deleteOrder,
|
permissions.deleteOrder,
|
||||||
permissions.updateAccount,
|
permissions.updateAccount,
|
||||||
|
permissions.updateAuthDevice,
|
||||||
permissions.updateOrder,
|
permissions.updateOrder,
|
||||||
permissions.updateUserSettings
|
permissions.updateUserSettings
|
||||||
];
|
];
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import { Account, Order, Platform } from '@prisma/client';
|
import { Account, Order, Platform, SymbolProfile } from '@prisma/client';
|
||||||
|
|
||||||
type AccountWithPlatform = Account & { Platform?: Platform };
|
type AccountWithPlatform = Account & { Platform?: Platform };
|
||||||
|
|
||||||
export type OrderWithAccount = Order & { Account?: AccountWithPlatform };
|
export type OrderWithAccount = Order & {
|
||||||
|
Account?: AccountWithPlatform;
|
||||||
|
SymbolProfile?: SymbolProfile;
|
||||||
|
};
|
||||||
|
14
package.json
14
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ghostfolio",
|
"name": "ghostfolio",
|
||||||
"version": "1.10.1",
|
"version": "1.16.0",
|
||||||
"homepage": "https://ghostfol.io",
|
"homepage": "https://ghostfol.io",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -18,7 +18,7 @@
|
|||||||
"database:format-schema": "prisma format",
|
"database:format-schema": "prisma format",
|
||||||
"database:generate-typings": "prisma generate",
|
"database:generate-typings": "prisma generate",
|
||||||
"database:gui": "prisma studio",
|
"database:gui": "prisma studio",
|
||||||
"database:push": "prisma db push --preview-feature",
|
"database:push": "prisma db push",
|
||||||
"database:seed": "prisma db seed --preview-feature",
|
"database:seed": "prisma db seed --preview-feature",
|
||||||
"dep-graph": "nx dep-graph",
|
"dep-graph": "nx dep-graph",
|
||||||
"e2e": "ng e2e",
|
"e2e": "ng e2e",
|
||||||
@ -65,7 +65,10 @@
|
|||||||
"@nestjs/schedule": "0.4.1",
|
"@nestjs/schedule": "0.4.1",
|
||||||
"@nestjs/serve-static": "2.1.4",
|
"@nestjs/serve-static": "2.1.4",
|
||||||
"@nrwl/angular": "12.0.0",
|
"@nrwl/angular": "12.0.0",
|
||||||
"@prisma/client": "2.20.1",
|
"@prisma/client": "2.24.1",
|
||||||
|
"@simplewebauthn/browser": "3.0.0",
|
||||||
|
"@simplewebauthn/server": "3.0.0",
|
||||||
|
"@simplewebauthn/typescript-types": "3.0.0",
|
||||||
"@types/lodash": "4.14.168",
|
"@types/lodash": "4.14.168",
|
||||||
"alphavantage": "2.2.0",
|
"alphavantage": "2.2.0",
|
||||||
"angular-material-css-vars": "1.1.2",
|
"angular-material-css-vars": "1.1.2",
|
||||||
@ -75,10 +78,10 @@
|
|||||||
"cache-manager-redis-store": "2.0.0",
|
"cache-manager-redis-store": "2.0.0",
|
||||||
"chart.js": "3.2.1",
|
"chart.js": "3.2.1",
|
||||||
"chartjs-adapter-date-fns": "1.1.0-beta.1",
|
"chartjs-adapter-date-fns": "1.1.0-beta.1",
|
||||||
"chartjs-chart-timeline": "0.4.0",
|
|
||||||
"cheerio": "1.0.0-rc.6",
|
"cheerio": "1.0.0-rc.6",
|
||||||
"class-transformer": "0.3.2",
|
"class-transformer": "0.3.2",
|
||||||
"class-validator": "0.13.1",
|
"class-validator": "0.13.1",
|
||||||
|
"countries-list": "2.6.1",
|
||||||
"countup.js": "2.0.7",
|
"countup.js": "2.0.7",
|
||||||
"cryptocurrencies": "7.0.0",
|
"cryptocurrencies": "7.0.0",
|
||||||
"date-fns": "2.19.0",
|
"date-fns": "2.19.0",
|
||||||
@ -92,10 +95,11 @@
|
|||||||
"passport": "0.4.1",
|
"passport": "0.4.1",
|
||||||
"passport-google-oauth20": "2.0.0",
|
"passport-google-oauth20": "2.0.0",
|
||||||
"passport-jwt": "4.0.0",
|
"passport-jwt": "4.0.0",
|
||||||
"prisma": "2.20.1",
|
"prisma": "2.24.1",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"round-to": "5.0.0",
|
"round-to": "5.0.0",
|
||||||
"rxjs": "6.6.7",
|
"rxjs": "6.6.7",
|
||||||
|
"svgmap": "2.1.1",
|
||||||
"uuid": "8.3.2",
|
"uuid": "8.3.2",
|
||||||
"yahoo-finance": "0.3.6",
|
"yahoo-finance": "0.3.6",
|
||||||
"zone.js": "0.11.4"
|
"zone.js": "0.11.4"
|
||||||
|
171
prisma/migrations/20210604190809_initial_migration/migration.sql
Normal file
171
prisma/migrations/20210604190809_initial_migration/migration.sql
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AccountType" AS ENUM ('SECURITIES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Currency" AS ENUM ('CHF', 'EUR', 'USD', 'GBP');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DataSource" AS ENUM ('GHOSTFOLIO', 'RAKUTEN', 'YAHOO', 'ALPHA_VANTAGE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ViewMode" AS ENUM ('DEFAULT', 'ZEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Provider" AS ENUM ('GOOGLE', 'ANONYMOUS');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'DEMO');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Type" AS ENUM ('BUY', 'SELL');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Access" (
|
||||||
|
"granteeUserId" TEXT NOT NULL,
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id","userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Account" (
|
||||||
|
"accountType" "AccountType" NOT NULL DEFAULT E'SECURITIES',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"isDefault" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"name" TEXT,
|
||||||
|
"platformId" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id","userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Analytics" (
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"activityCount" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MarketData" (
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"symbol" TEXT NOT NULL,
|
||||||
|
"marketPrice" DOUBLE PRECISION NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Order" (
|
||||||
|
"currency" "Currency" NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"fee" DOUBLE PRECISION NOT NULL,
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"quantity" DOUBLE PRECISION NOT NULL,
|
||||||
|
"symbol" TEXT NOT NULL,
|
||||||
|
"type" "Type" NOT NULL,
|
||||||
|
"unitPrice" DOUBLE PRECISION NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"accountId" TEXT,
|
||||||
|
"accountUserId" TEXT,
|
||||||
|
"dataSource" "DataSource" NOT NULL DEFAULT E'YAHOO',
|
||||||
|
|
||||||
|
PRIMARY KEY ("id","userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Platform" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Property" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("key")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Settings" (
|
||||||
|
"currency" "Currency",
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"viewMode" "ViewMode",
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Subscription" (
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id","userId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"provider" "Provider",
|
||||||
|
"thirdPartyId" TEXT,
|
||||||
|
"accessToken" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"role" "Role" NOT NULL DEFAULT E'USER',
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"alias" TEXT,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MarketData.date_symbol_unique" ON "MarketData"("date", "symbol");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketData.symbol_index" ON "MarketData"("symbol");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Platform.url_unique" ON "Platform"("url");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Access" ADD FOREIGN KEY ("granteeUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Access" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Account" ADD FOREIGN KEY ("platformId") REFERENCES "Platform"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Account" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Analytics" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Order" ADD FOREIGN KEY ("accountId", "accountUserId") REFERENCES "Account"("id", "userId") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Order" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Settings" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Subscription" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@ -0,0 +1,21 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Order" ADD COLUMN "symbolProfileId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SymbolProfile" (
|
||||||
|
"countries" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"dataSource" "DataSource" NOT NULL,
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"symbol" TEXT NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SymbolProfile.dataSource_symbol_unique" ON "SymbolProfile"("dataSource", "symbol");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Order" ADD FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
@ -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;
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "postgresql"
|
@ -47,6 +47,17 @@ model Analytics {
|
|||||||
userId String @id
|
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 {
|
model MarketData {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
date DateTime
|
date DateTime
|
||||||
@ -59,22 +70,24 @@ model MarketData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
|
Account Account? @relation(fields: [accountId, accountUserId], references: [id, userId])
|
||||||
accountId String?
|
accountId String?
|
||||||
accountUserId String?
|
accountUserId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
currency Currency
|
currency Currency
|
||||||
dataSource DataSource @default(YAHOO)
|
dataSource DataSource @default(YAHOO)
|
||||||
date DateTime
|
date DateTime
|
||||||
fee Float
|
fee Float
|
||||||
id String @default(uuid())
|
id String @default(uuid())
|
||||||
quantity Float
|
quantity Float
|
||||||
symbol String
|
symbol String
|
||||||
type Type
|
SymbolProfile SymbolProfile? @relation(fields: [symbolProfileId], references: [id])
|
||||||
unitPrice Float
|
symbolProfileId String?
|
||||||
updatedAt DateTime @updatedAt
|
type Type
|
||||||
User User @relation(fields: [userId], references: [id])
|
unitPrice Float
|
||||||
userId String
|
updatedAt DateTime @updatedAt
|
||||||
|
User User @relation(fields: [userId], references: [id])
|
||||||
|
userId String
|
||||||
|
|
||||||
@@id([id, userId])
|
@@id([id, userId])
|
||||||
}
|
}
|
||||||
@ -99,6 +112,19 @@ model Settings {
|
|||||||
userId String @id
|
userId String @id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SymbolProfile {
|
||||||
|
countries Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
dataSource DataSource
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String?
|
||||||
|
Order Order[]
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
symbol String
|
||||||
|
|
||||||
|
@@unique([dataSource, symbol])
|
||||||
|
}
|
||||||
|
|
||||||
model Subscription {
|
model Subscription {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
@ -111,21 +137,23 @@ model Subscription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
Access Access[] @relation("accessGet")
|
Access Access[] @relation("accessGet")
|
||||||
AccessGive Access[] @relation(name: "accessGive")
|
AccessGive Access[] @relation(name: "accessGive")
|
||||||
accessToken String?
|
accessToken String?
|
||||||
Account Account[]
|
Account Account[]
|
||||||
alias String?
|
alias String?
|
||||||
Analytics Analytics?
|
Analytics Analytics?
|
||||||
createdAt DateTime @default(now())
|
authChallenge String?
|
||||||
id String @id @default(uuid())
|
AuthDevice AuthDevice[]
|
||||||
Order Order[]
|
createdAt DateTime @default(now())
|
||||||
provider Provider?
|
id String @id @default(uuid())
|
||||||
role Role @default(USER)
|
Order Order[]
|
||||||
Settings Settings?
|
provider Provider?
|
||||||
Subscription Subscription[]
|
role Role @default(USER)
|
||||||
thirdPartyId String?
|
Settings Settings?
|
||||||
updatedAt DateTime @updatedAt
|
Subscription Subscription[]
|
||||||
|
thirdPartyId String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AccountType {
|
enum AccountType {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AccountType,
|
AccountType,
|
||||||
Currency,
|
Currency,
|
||||||
|
DataSource,
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
Role,
|
Role,
|
||||||
Type
|
Type
|
||||||
@ -135,17 +136,53 @@ async function main() {
|
|||||||
where: { id: '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f' }
|
where: { id: '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await prisma.symbolProfile.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
countries: [{ code: 'US', weight: 1 }],
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
id: '2bd26362-136e-411c-b578-334084b4cdcc',
|
||||||
|
symbol: 'AMZN'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
countries: null,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
id: 'fdc42ea6-1321-44f5-9fb0-d7f1f2cf9b1e',
|
||||||
|
symbol: 'BTCUSD'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
countries: [{ code: 'US', weight: 1 }],
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
id: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
|
||||||
|
symbol: 'TSLA'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
countries: [
|
||||||
|
{ code: 'US', weight: 0.9886789999999981 },
|
||||||
|
{ code: 'NL', weight: 0.000203 },
|
||||||
|
{ code: 'CA', weight: 0.000362 }
|
||||||
|
],
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
id: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||||
|
symbol: 'VTI'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
skipDuplicates: true
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.order.createMany({
|
await prisma.order.createMany({
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
accountId: '65cfb79d-b6c7-4591-9d46-73426bc62094',
|
accountId: '65cfb79d-b6c7-4591-9d46-73426bc62094',
|
||||||
accountUserId: userDemo.id,
|
accountUserId: userDemo.id,
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: new Date(Date.UTC(2017, 0, 3, 0, 0, 0)),
|
date: new Date(Date.UTC(2017, 0, 3, 0, 0, 0)),
|
||||||
fee: 30,
|
fee: 30,
|
||||||
id: 'cf7c0418-8535-4089-ae3d-5dbfa0aec2e1',
|
id: 'cf7c0418-8535-4089-ae3d-5dbfa0aec2e1',
|
||||||
quantity: 50,
|
quantity: 50,
|
||||||
symbol: 'TSLA',
|
symbol: 'TSLA',
|
||||||
|
symbolProfileId: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 42.97,
|
unitPrice: 42.97,
|
||||||
userId: userDemo.id
|
userId: userDemo.id
|
||||||
@ -154,11 +191,13 @@ async function main() {
|
|||||||
accountId: 'd804de69-0429-42dc-b6ca-b308fd7dd926',
|
accountId: 'd804de69-0429-42dc-b6ca-b308fd7dd926',
|
||||||
accountUserId: userDemo.id,
|
accountUserId: userDemo.id,
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: new Date(Date.UTC(2017, 7, 16, 0, 0, 0)),
|
date: new Date(Date.UTC(2017, 7, 16, 0, 0, 0)),
|
||||||
fee: 29.9,
|
fee: 29.9,
|
||||||
id: 'a1c5d73a-8631-44e5-ac44-356827a5212c',
|
id: 'a1c5d73a-8631-44e5-ac44-356827a5212c',
|
||||||
quantity: 0.5614682,
|
quantity: 0.5614682,
|
||||||
symbol: 'BTCUSD',
|
symbol: 'BTCUSD',
|
||||||
|
symbolProfileId: 'fdc42ea6-1321-44f5-9fb0-d7f1f2cf9b1e',
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 3562.089535970158,
|
unitPrice: 3562.089535970158,
|
||||||
userId: userDemo.id
|
userId: userDemo.id
|
||||||
@ -167,11 +206,13 @@ async function main() {
|
|||||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||||
accountUserId: userDemo.id,
|
accountUserId: userDemo.id,
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: new Date(Date.UTC(2018, 9, 1, 0, 0, 0)),
|
date: new Date(Date.UTC(2018, 9, 1, 0, 0, 0)),
|
||||||
fee: 80.79,
|
fee: 80.79,
|
||||||
id: '71c08e2a-4a86-44ae-a890-c337de5d5f9b',
|
id: '71c08e2a-4a86-44ae-a890-c337de5d5f9b',
|
||||||
quantity: 5,
|
quantity: 5,
|
||||||
symbol: 'AMZN',
|
symbol: 'AMZN',
|
||||||
|
symbolProfileId: '2bd26362-136e-411c-b578-334084b4cdcc',
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 2021.99,
|
unitPrice: 2021.99,
|
||||||
userId: userDemo.id
|
userId: userDemo.id
|
||||||
@ -180,11 +221,13 @@ async function main() {
|
|||||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||||
accountUserId: userDemo.id,
|
accountUserId: userDemo.id,
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: new Date(Date.UTC(2019, 2, 1, 0, 0, 0)),
|
date: new Date(Date.UTC(2019, 2, 1, 0, 0, 0)),
|
||||||
fee: 19.9,
|
fee: 19.9,
|
||||||
id: '385f2c2c-d53e-4937-b0e5-e92ef6020d4e',
|
id: '385f2c2c-d53e-4937-b0e5-e92ef6020d4e',
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
symbol: 'VTI',
|
symbol: 'VTI',
|
||||||
|
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 144.38,
|
unitPrice: 144.38,
|
||||||
userId: userDemo.id
|
userId: userDemo.id
|
||||||
@ -193,11 +236,13 @@ async function main() {
|
|||||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||||
accountUserId: userDemo.id,
|
accountUserId: userDemo.id,
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: new Date(Date.UTC(2019, 8, 3, 0, 0, 0)),
|
date: new Date(Date.UTC(2019, 8, 3, 0, 0, 0)),
|
||||||
fee: 19.9,
|
fee: 19.9,
|
||||||
id: '185f2c2c-d53e-4937-b0e5-a93ef6020d4e',
|
id: '185f2c2c-d53e-4937-b0e5-a93ef6020d4e',
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
symbol: 'VTI',
|
symbol: 'VTI',
|
||||||
|
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 147.99,
|
unitPrice: 147.99,
|
||||||
userId: userDemo.id
|
userId: userDemo.id
|
||||||
@ -206,11 +251,13 @@ async function main() {
|
|||||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||||
accountUserId: userDemo.id,
|
accountUserId: userDemo.id,
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: new Date(Date.UTC(2020, 2, 2, 0, 0, 0)),
|
date: new Date(Date.UTC(2020, 2, 2, 0, 0, 0)),
|
||||||
fee: 19.9,
|
fee: 19.9,
|
||||||
id: '347b0430-a84f-4031-a0f9-390399066ad6',
|
id: '347b0430-a84f-4031-a0f9-390399066ad6',
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
symbol: 'VTI',
|
symbol: 'VTI',
|
||||||
|
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 151.41,
|
unitPrice: 151.41,
|
||||||
userId: userDemo.id
|
userId: userDemo.id
|
||||||
@ -219,11 +266,13 @@ async function main() {
|
|||||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||||
accountUserId: userDemo.id,
|
accountUserId: userDemo.id,
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: new Date(Date.UTC(2020, 8, 1, 0, 0, 0)),
|
date: new Date(Date.UTC(2020, 8, 1, 0, 0, 0)),
|
||||||
fee: 19.9,
|
fee: 19.9,
|
||||||
id: '67ec3f47-3189-4b63-ba05-60d3a06b302f',
|
id: '67ec3f47-3189-4b63-ba05-60d3a06b302f',
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
symbol: 'VTI',
|
symbol: 'VTI',
|
||||||
|
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 177.69,
|
unitPrice: 177.69,
|
||||||
userId: userDemo.id
|
userId: userDemo.id
|
||||||
@ -232,11 +281,13 @@ async function main() {
|
|||||||
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
|
||||||
accountUserId: userDemo.id,
|
accountUserId: userDemo.id,
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
date: new Date(Date.UTC(2020, 2, 1, 0, 0, 0)),
|
date: new Date(Date.UTC(2020, 2, 1, 0, 0, 0)),
|
||||||
fee: 19.9,
|
fee: 19.9,
|
||||||
id: 'd01c6fbc-fa8d-47e6-8e80-66f882d2bfd2',
|
id: 'd01c6fbc-fa8d-47e6-8e80-66f882d2bfd2',
|
||||||
quantity: 10,
|
quantity: 10,
|
||||||
symbol: 'VTI',
|
symbol: 'VTI',
|
||||||
|
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 203.15,
|
unitPrice: 203.15,
|
||||||
userId: userDemo.id
|
userId: userDemo.id
|
||||||
|
195
yarn.lock
195
yarn.lock
@ -2081,22 +2081,52 @@
|
|||||||
consola "^2.15.0"
|
consola "^2.15.0"
|
||||||
node-fetch "^2.6.1"
|
node-fetch "^2.6.1"
|
||||||
|
|
||||||
"@prisma/client@2.20.1":
|
"@peculiar/asn1-android@^2.0.26":
|
||||||
version "2.20.1"
|
version "2.0.36"
|
||||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.20.1.tgz#45e7bade3a1b58972fc35e511578e313ead18770"
|
resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.0.36.tgz#8c7f9025b04850620afcee8fdcd418295730cd48"
|
||||||
integrity sha512-/IYPubBi55rNMHfE0wwglA6eTWEZD77oz+x+3Mm9ji2lDKdS1lnYKZ0wZX0E3AB8gTNL/zsGtfzmfjgn3ePyIw==
|
integrity sha512-8Ul9zVgqDR2H2DoWPeJYFqbDQBTceZVaVqy06fo/PB7YiDdXcPQJL1b29fs6n4wKj70PVz6JhlwKgHJby1EWTQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@prisma/engines-version" "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
|
"@peculiar/asn1-schema" "^2.0.36"
|
||||||
|
asn1js "^2.1.1"
|
||||||
|
tslib "^2.2.0"
|
||||||
|
|
||||||
"@prisma/engines-version@2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e":
|
"@peculiar/asn1-schema@^2.0.26", "@peculiar/asn1-schema@^2.0.36":
|
||||||
version "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
|
version "2.0.36"
|
||||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e.tgz#1189f0a7e682f500015446cfe2b34d2753452190"
|
resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.0.36.tgz#ca7978f43ffa4f35fbb74436c3f983c10a69ac27"
|
||||||
integrity sha512-fJhbGZXm2SPs/RsI79Ew4SFe+6QmChNdgU2I/SIjmU18bUgK8f1TBEWnVtFdBqEDHYPGxbpaianF7lp04KN7EA==
|
integrity sha512-x7fdMR6bzOBct2a0PLukrmVrrehHX5uisKRDWN2Bs1HojXd5nCi7MAQeV+umRxPK1oSJDstTBhGq3sLzDbL8Vw==
|
||||||
|
dependencies:
|
||||||
|
"@types/asn1js" "^2.0.0"
|
||||||
|
asn1js "^2.1.1"
|
||||||
|
pvtsutils "^1.1.7"
|
||||||
|
tslib "^2.2.0"
|
||||||
|
|
||||||
"@prisma/engines@2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e":
|
"@peculiar/asn1-x509@^2.0.26":
|
||||||
version "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
|
version "2.0.36"
|
||||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e.tgz#18f23c4a8a335a93fd338f88c310f5cf120f5591"
|
resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.0.36.tgz#fc1ba09d359e3fbf8062e29fc659393a14086ada"
|
||||||
integrity sha512-zOWETm7DTRvlwf/CekPNSeJe6EC5bn2IFexd74wM9zgBXCZo+1sMDuNGtCqIt4Rzv8CcimEgyzrEFVq0LPV8qg==
|
integrity sha512-E7+7Y3tp524/tzODhELIxme636wx2JNrtHqPOTJb1a67gSYjNQrO4MXWaHccFxgCgkTa8eWoVnOkhX4lPT4j2w==
|
||||||
|
dependencies:
|
||||||
|
"@peculiar/asn1-schema" "^2.0.36"
|
||||||
|
asn1js "^2.1.1"
|
||||||
|
ipaddr.js "^2.0.0"
|
||||||
|
pvtsutils "^1.1.7"
|
||||||
|
tslib "^2.2.0"
|
||||||
|
|
||||||
|
"@prisma/client@2.24.1":
|
||||||
|
version "2.24.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.24.1.tgz#c4f26fb4d768dd52dd20a17e626f10e69cc0b85c"
|
||||||
|
integrity sha512-vllhf36g3oI98GF1Q5IPmnR5MYzBPeCcl/Xiz6EAi4DMOxE069o9ka5BAqYbUG2USx8JuKw09QdMnDrp3Kyn8g==
|
||||||
|
dependencies:
|
||||||
|
"@prisma/engines-version" "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
|
||||||
|
|
||||||
|
"@prisma/engines-version@2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4":
|
||||||
|
version "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4.tgz#2c5813ef98bcbe659b18b521f002f5c8aabbaae2"
|
||||||
|
integrity sha512-60Do+ByVfHnhJ2id5h/lXOZnDQNIf5pz3enkKWOmyr744Z2IxkBu65jRckFfMN5cPtmXDre/Ay/GKm0aoeLwrw==
|
||||||
|
|
||||||
|
"@prisma/engines@2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4":
|
||||||
|
version "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4.tgz#7e542d510f0c03f41b73edbb17254f5a0b272a4d"
|
||||||
|
integrity sha512-29/xO9kqeQka+wN5Ev10l5L4XQXNVXdPToJs1M29VZ2imQsNsL4rtz26m3qGM54IoGWwwfTVdvuVRxKnDl2rig==
|
||||||
|
|
||||||
"@samverschueren/stream-to-observable@^0.3.0":
|
"@samverschueren/stream-to-observable@^0.3.0":
|
||||||
version "0.3.1"
|
version "0.3.1"
|
||||||
@ -2137,6 +2167,33 @@
|
|||||||
semver "7.3.4"
|
semver "7.3.4"
|
||||||
semver-intersect "1.4.0"
|
semver-intersect "1.4.0"
|
||||||
|
|
||||||
|
"@simplewebauthn/browser@3.0.0":
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-3.0.0.tgz#3d76b199c9f474408a7ed75d86004423dd6ae38a"
|
||||||
|
integrity sha512-P661gZX/QW0Rg2NRAMtW84Q3u4nhXkPef9LLU4btLJFYoXO8RBFfxcmyqwyf2QEb4B7+lFdp5EWfZV5T7FvuHw==
|
||||||
|
|
||||||
|
"@simplewebauthn/server@3.0.0":
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-3.0.0.tgz#eb1a5bbe2ecdda54363b178f4bb3e134f25641f0"
|
||||||
|
integrity sha512-ymGX2obBrhY9R3OxrpCYaNGAovFHmMlQrGoNdVOe2R2JUBXC1Rg5JEUl1lGyaRykN1SyZqLgz86wAjDVuRITTA==
|
||||||
|
dependencies:
|
||||||
|
"@peculiar/asn1-android" "^2.0.26"
|
||||||
|
"@peculiar/asn1-schema" "^2.0.26"
|
||||||
|
"@peculiar/asn1-x509" "^2.0.26"
|
||||||
|
"@simplewebauthn/typescript-types" "^3.0.0"
|
||||||
|
base64url "^3.0.1"
|
||||||
|
cbor "^5.1.0"
|
||||||
|
elliptic "^6.5.3"
|
||||||
|
jsrsasign "^10.2.0"
|
||||||
|
jwk-to-pem "^2.0.4"
|
||||||
|
node-fetch "^2.6.0"
|
||||||
|
node-rsa "^1.1.1"
|
||||||
|
|
||||||
|
"@simplewebauthn/typescript-types@3.0.0", "@simplewebauthn/typescript-types@^3.0.0":
|
||||||
|
version "3.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-3.0.0.tgz#6712e9619d860f54f571cd27dbe167b2d9e5ab87"
|
||||||
|
integrity sha512-bsk3EQWzPOZwP9C+ETVhcFDpZywY5sTqmNuGkNm3aNpc9Xh/mqZjy8nL0Sm7xwrlhY0zWAlOaIWQ3LvN5SoFhg==
|
||||||
|
|
||||||
"@sinonjs/commons@^1.7.0":
|
"@sinonjs/commons@^1.7.0":
|
||||||
version "1.8.3"
|
version "1.8.3"
|
||||||
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
|
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
|
||||||
@ -2161,6 +2218,11 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
|
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
|
||||||
integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==
|
integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==
|
||||||
|
|
||||||
|
"@types/asn1js@^2.0.0":
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/asn1js/-/asn1js-2.0.0.tgz#10ca75692575744d0117098148a8dc84cbee6682"
|
||||||
|
integrity sha512-Jjzp5EqU0hNpADctc/UqhiFbY1y2MqIxBVa2S4dBlbnZHTLPMuggoL5q43X63LpsOIINRDirBjP56DUUKIUWIA==
|
||||||
|
|
||||||
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
|
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
|
||||||
version "7.1.14"
|
version "7.1.14"
|
||||||
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402"
|
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402"
|
||||||
@ -3254,7 +3316,7 @@ array.prototype.flat@^1.2.3:
|
|||||||
define-properties "^1.1.3"
|
define-properties "^1.1.3"
|
||||||
es-abstract "^1.18.0-next.1"
|
es-abstract "^1.18.0-next.1"
|
||||||
|
|
||||||
asn1.js@^5.2.0:
|
asn1.js@^5.2.0, asn1.js@^5.3.0:
|
||||||
version "5.4.1"
|
version "5.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
|
resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
|
||||||
integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
|
integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
|
||||||
@ -3264,13 +3326,20 @@ asn1.js@^5.2.0:
|
|||||||
minimalistic-assert "^1.0.0"
|
minimalistic-assert "^1.0.0"
|
||||||
safer-buffer "^2.1.0"
|
safer-buffer "^2.1.0"
|
||||||
|
|
||||||
asn1@~0.2.3:
|
asn1@^0.2.4, asn1@~0.2.3:
|
||||||
version "0.2.4"
|
version "0.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
|
resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
|
||||||
integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
|
integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer "~2.1.0"
|
safer-buffer "~2.1.0"
|
||||||
|
|
||||||
|
asn1js@^2.1.1:
|
||||||
|
version "2.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-2.1.1.tgz#bb3896191ebb5fb1caeda73436a6c6e20a2eedff"
|
||||||
|
integrity sha512-t9u0dU0rJN4ML+uxgN6VM2Z4H5jWIYm0w8LsZLzMJaQsgL3IJNbxHgmbWDvJAwspyHpDFuzUaUFh4c05UB4+6g==
|
||||||
|
dependencies:
|
||||||
|
pvutils latest
|
||||||
|
|
||||||
assert-plus@1.0.0, assert-plus@^1.0.0:
|
assert-plus@1.0.0, assert-plus@^1.0.0:
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
|
resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
|
||||||
@ -3503,7 +3572,7 @@ base64-js@^1.0.2, base64-js@^1.3.1:
|
|||||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||||
|
|
||||||
base64url@3.x.x:
|
base64url@3.x.x, base64url@^3.0.1:
|
||||||
version "3.0.1"
|
version "3.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
|
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
|
||||||
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
|
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
|
||||||
@ -3547,6 +3616,11 @@ big.js@^5.2.2:
|
|||||||
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
|
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
|
||||||
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
|
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
|
||||||
|
|
||||||
|
bignumber.js@^9.0.1:
|
||||||
|
version "9.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5"
|
||||||
|
integrity sha512-IdZR9mh6ahOBv/hYGiXyVuyCetmGJhtYkqLBpTStdhEGjegpPlUawydyaF3pbIOFynJTpllEs+NP+CS9jKFLjA==
|
||||||
|
|
||||||
binary-extensions@^1.0.0:
|
binary-extensions@^1.0.0:
|
||||||
version "1.13.1"
|
version "1.13.1"
|
||||||
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
|
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
|
||||||
@ -4025,6 +4099,14 @@ caseless@~0.12.0:
|
|||||||
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
|
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
|
||||||
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
|
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
|
||||||
|
|
||||||
|
cbor@^5.1.0:
|
||||||
|
version "5.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/cbor/-/cbor-5.2.0.tgz#4cca67783ccd6de7b50ab4ed62636712f287a67c"
|
||||||
|
integrity sha512-5IMhi9e1QU76ppa5/ajP1BmMWZ2FHkhAhjeVKQ/EFCgYSEaeVaoGtL7cxJskf9oCCk+XjzaIdc3IuU/dbA/o2A==
|
||||||
|
dependencies:
|
||||||
|
bignumber.js "^9.0.1"
|
||||||
|
nofilter "^1.0.4"
|
||||||
|
|
||||||
chalk@4.1.0:
|
chalk@4.1.0:
|
||||||
version "4.1.0"
|
version "4.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
|
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
|
||||||
@ -4081,11 +4163,6 @@ chartjs-adapter-date-fns@1.1.0-beta.1:
|
|||||||
resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-1.1.0-beta.1.tgz#5502dbfe33ff62af44ce6a05df086d1be773469a"
|
resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-1.1.0-beta.1.tgz#5502dbfe33ff62af44ce6a05df086d1be773469a"
|
||||||
integrity sha512-VNhuZ86kXKOwh61CyRLP7hoFqAR7+gjnrtf7KYLt/Wfh3jIQs14l1h+nagtQoFaabIYIo6UD5/jJb2/J6zOPcw==
|
integrity sha512-VNhuZ86kXKOwh61CyRLP7hoFqAR7+gjnrtf7KYLt/Wfh3jIQs14l1h+nagtQoFaabIYIo6UD5/jJb2/J6zOPcw==
|
||||||
|
|
||||||
chartjs-chart-timeline@0.4.0:
|
|
||||||
version "0.4.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/chartjs-chart-timeline/-/chartjs-chart-timeline-0.4.0.tgz#cbd25dc5ddb5c2b34289f8dd7a2a627d71e251e8"
|
|
||||||
integrity sha512-a3iOFgMUXgEK9zyDFXlL7cfhO6z4DkeuGqok1xnNVNg12ciSt/k1jDBFk8JKN+sVNZfoqeGAFBT9zvb++iEWnA==
|
|
||||||
|
|
||||||
check-more-types@^2.24.0:
|
check-more-types@^2.24.0:
|
||||||
version "2.24.0"
|
version "2.24.0"
|
||||||
resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600"
|
resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600"
|
||||||
@ -4698,6 +4775,11 @@ cosmiconfig@^7.0.0:
|
|||||||
path-type "^4.0.0"
|
path-type "^4.0.0"
|
||||||
yaml "^1.10.0"
|
yaml "^1.10.0"
|
||||||
|
|
||||||
|
countries-list@2.6.1:
|
||||||
|
version "2.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/countries-list/-/countries-list-2.6.1.tgz#d479757ac873b1e596ccea0a925962d20396c0cb"
|
||||||
|
integrity sha512-jXM1Nv3U56dPQ1DsUSsEaGmLHburo4fnB7m+1yhWDUVvx5gXCd1ok/y3gXCjXzhqyawG+igcPYcAl4qjkvopaQ==
|
||||||
|
|
||||||
countup.js@2.0.7:
|
countup.js@2.0.7:
|
||||||
version "2.0.7"
|
version "2.0.7"
|
||||||
resolved "https://registry.yarnpkg.com/countup.js/-/countup.js-2.0.7.tgz#56b72a87fc0ee3cadb38356c246ccac88fb0a8cc"
|
resolved "https://registry.yarnpkg.com/countup.js/-/countup.js-2.0.7.tgz#56b72a87fc0ee3cadb38356c246ccac88fb0a8cc"
|
||||||
@ -5505,7 +5587,7 @@ elegant-spinner@^1.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e"
|
resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e"
|
||||||
integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=
|
integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=
|
||||||
|
|
||||||
elliptic@^6.5.3:
|
elliptic@^6.5.3, elliptic@^6.5.4:
|
||||||
version "6.5.4"
|
version "6.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
|
resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.4.tgz#da37cebd31e79a1367e941b592ed1fbebd58abbb"
|
||||||
integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
|
integrity sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==
|
||||||
@ -7389,6 +7471,11 @@ ipaddr.js@1.9.1, ipaddr.js@^1.9.0:
|
|||||||
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
|
||||||
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
|
integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
|
||||||
|
|
||||||
|
ipaddr.js@^2.0.0:
|
||||||
|
version "2.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.0.tgz#77ccccc8063ae71ab65c55f21b090698e763fc6e"
|
||||||
|
integrity sha512-S54H9mIj0rbxRIyrDMEuuER86LdlgUg9FSeZ8duQb6CUG2iRrA36MYVQBSprTF/ZeAwvyQ5mDGuNvIPM0BIl3w==
|
||||||
|
|
||||||
is-absolute-url@^2.0.0:
|
is-absolute-url@^2.0.0:
|
||||||
version "2.1.0"
|
version "2.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6"
|
resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6"
|
||||||
@ -8406,6 +8493,11 @@ jsprim@^1.2.2:
|
|||||||
json-schema "0.2.3"
|
json-schema "0.2.3"
|
||||||
verror "1.10.0"
|
verror "1.10.0"
|
||||||
|
|
||||||
|
jsrsasign@^10.2.0:
|
||||||
|
version "10.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-10.3.0.tgz#540d7c6937da1d5b01699d5091e56378a33e246e"
|
||||||
|
integrity sha512-irDIKKFW++EAELgP3fjFi5/Fn0XEyfuQTTgpbeFwCGkV6tRIYZl3uraRea2HTXWCstcSZuDaCbdAhU1n+075Bg==
|
||||||
|
|
||||||
jwa@^1.4.1:
|
jwa@^1.4.1:
|
||||||
version "1.4.1"
|
version "1.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
|
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
|
||||||
@ -8415,6 +8507,15 @@ jwa@^1.4.1:
|
|||||||
ecdsa-sig-formatter "1.0.11"
|
ecdsa-sig-formatter "1.0.11"
|
||||||
safe-buffer "^5.0.1"
|
safe-buffer "^5.0.1"
|
||||||
|
|
||||||
|
jwk-to-pem@^2.0.4:
|
||||||
|
version "2.0.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz#151310bcfbcf731adc5ad9f379cbc8b395742906"
|
||||||
|
integrity sha512-L90jwellhO8jRKYwbssU9ifaMVqajzj3fpRjDKcsDzrslU9syRbFqfkXtT4B89HYAap+xsxNcxgBSB09ig+a7A==
|
||||||
|
dependencies:
|
||||||
|
asn1.js "^5.3.0"
|
||||||
|
elliptic "^6.5.4"
|
||||||
|
safe-buffer "^5.0.1"
|
||||||
|
|
||||||
jws@^3.2.2:
|
jws@^3.2.2:
|
||||||
version "3.2.2"
|
version "3.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
|
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
|
||||||
@ -9358,7 +9459,7 @@ nice-try@^1.0.4:
|
|||||||
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
|
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
|
||||||
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
|
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
|
||||||
|
|
||||||
node-fetch@2.6.1, node-fetch@^2.6.1:
|
node-fetch@2.6.1, node-fetch@^2.6.0, node-fetch@^2.6.1:
|
||||||
version "2.6.1"
|
version "2.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
|
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
|
||||||
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
|
integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
|
||||||
@ -9440,6 +9541,18 @@ node-releases@^1.1.71:
|
|||||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb"
|
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb"
|
||||||
integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg==
|
integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg==
|
||||||
|
|
||||||
|
node-rsa@^1.1.1:
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/node-rsa/-/node-rsa-1.1.1.tgz#efd9ad382097782f506153398496f79e4464434d"
|
||||||
|
integrity sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==
|
||||||
|
dependencies:
|
||||||
|
asn1 "^0.2.4"
|
||||||
|
|
||||||
|
nofilter@^1.0.4:
|
||||||
|
version "1.0.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/nofilter/-/nofilter-1.0.4.tgz#78d6f4b6a613e7ced8b015cec534625f7667006e"
|
||||||
|
integrity sha512-N8lidFp+fCz+TD51+haYdbDGrcBWwuHX40F5+z0qkUjMJ5Tp+rdSuAkMJ9N9eoolDlEVTf6u5icM+cNKkKW2mA==
|
||||||
|
|
||||||
nopt@^5.0.0:
|
nopt@^5.0.0:
|
||||||
version "5.0.0"
|
version "5.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
|
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
|
||||||
@ -10702,12 +10815,12 @@ pretty-format@26.x, pretty-format@^26.0.0, pretty-format@^26.6.2:
|
|||||||
ansi-styles "^4.0.0"
|
ansi-styles "^4.0.0"
|
||||||
react-is "^17.0.1"
|
react-is "^17.0.1"
|
||||||
|
|
||||||
prisma@2.20.1:
|
prisma@2.24.1:
|
||||||
version "2.20.1"
|
version "2.24.1"
|
||||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.20.1.tgz#28c52135523e0853258cd4ca7e883e9e4b5a9d40"
|
resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.24.1.tgz#f8f4cb8baf407a71800256160277f69603bd43a3"
|
||||||
integrity sha512-zyPvJSUfJrmciP2D/4aUrsyIefiH8AIJUeuq1a0X1df1AFw9QQ+ata/7VQdoP+RIQHnCb6Kln9kqfUw/fieljw==
|
integrity sha512-L+ykMpttbWzpTNsy+PPynnEX/mS1s5zs49euXBrMjxXh1M6/f9MYlTNAj+iP90O9ZSaURSpNa+1jzatPghqUcQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@prisma/engines" "2.20.0-26.60ba6551f29b17d7d6ce479e5733c70d9c00860e"
|
"@prisma/engines" "2.24.1-2.18095475d5ee64536e2f93995e48ad800737a9e4"
|
||||||
|
|
||||||
prismjs@^1.23.0:
|
prismjs@^1.23.0:
|
||||||
version "1.23.0"
|
version "1.23.0"
|
||||||
@ -10830,6 +10943,18 @@ punycode@^2.1.0, punycode@^2.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
|
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
|
||||||
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
|
integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
|
||||||
|
|
||||||
|
pvtsutils@^1.1.7:
|
||||||
|
version "1.1.7"
|
||||||
|
resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.1.7.tgz#39a65ccb3b7448c974f6a6141ce2aad037b3f13c"
|
||||||
|
integrity sha512-faOiD/XpB/cIebRzYwzYjCmYgiDd53YEBni+Mt1+8/HlrARHYBpsU2OHOt3EZ1ZhfRNxPL0dH3K/vKaMgNWVGA==
|
||||||
|
dependencies:
|
||||||
|
tslib "^2.2.0"
|
||||||
|
|
||||||
|
pvutils@latest:
|
||||||
|
version "1.0.17"
|
||||||
|
resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.0.17.tgz#ade3c74dfe7178944fe44806626bd2e249d996bf"
|
||||||
|
integrity sha512-wLHYUQxWaXVQvKnwIDWFVKDJku9XDCvyhhxoq8dc5MFdIlRenyPI9eSfEtcvgHgD7FlvCyGAlWgOzRnZD99GZQ==
|
||||||
|
|
||||||
q@^1.1.2:
|
q@^1.1.2:
|
||||||
version "1.5.1"
|
version "1.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
|
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
|
||||||
@ -12377,6 +12502,18 @@ supports-hyperlinks@^2.0.0:
|
|||||||
has-flag "^4.0.0"
|
has-flag "^4.0.0"
|
||||||
supports-color "^7.0.0"
|
supports-color "^7.0.0"
|
||||||
|
|
||||||
|
svg-pan-zoom@^3.6.1:
|
||||||
|
version "3.6.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/svg-pan-zoom/-/svg-pan-zoom-3.6.1.tgz#f880a1bb32d18e9c625d7715350bebc269b450cf"
|
||||||
|
integrity sha512-JaKkGHHfGvRrcMPdJWkssLBeWqM+Isg/a09H7kgNNajT1cX5AztDTNs+C8UzpCxjCTRrG34WbquwaovZbmSk9g==
|
||||||
|
|
||||||
|
svgmap@2.1.1:
|
||||||
|
version "2.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/svgmap/-/svgmap-2.1.1.tgz#355c259cf4e04b20d2d39bab05d0e718ade942ff"
|
||||||
|
integrity sha512-1blZYMYDXq8H3xykzgBJRh5q+XPd5JLOJ8K7UuZI6ab2D3hngiVcr+Z1olfy7DH9Xf9AOCTpt4Id7iVD8cKD0A==
|
||||||
|
dependencies:
|
||||||
|
svg-pan-zoom "^3.6.1"
|
||||||
|
|
||||||
svgo@^1.0.0:
|
svgo@^1.0.0:
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
|
resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"
|
||||||
@ -12769,7 +12906,7 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0:
|
|||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||||
|
|
||||||
tslib@^2.0.0, tslib@^2.1.0:
|
tslib@^2.0.0, tslib@^2.1.0, tslib@^2.2.0:
|
||||||
version "2.2.0"
|
version "2.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"
|
||||||
integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==
|
integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==
|
||||||
|
Reference in New Issue
Block a user