Compare commits

...

41 Commits

Author SHA1 Message Date
5618513d07 Update changelog (#446) 2021-10-31 17:02:41 +01:00
1397cd62a8 Feature/extend public page (#445)
* Extend public page

* Update changelog
2021-10-31 17:00:59 +01:00
e7fb31d1a6 add Dockerfile with all in one docker image (#431)
* Add Dockerfile with all in one docker image

* Change to alpine image and reduce node_modules size

* Improve documentation and fix changelog and license

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-10-30 18:54:14 +02:00
51e7b94ad0 Release 1.66.0 (#443) 2021-10-30 11:22:50 +02:00
9133ea38f3 Feature/order accesses (#441)
* Order accesses

* Update changelog
2021-10-30 10:49:17 +02:00
a864c617b9 Feature/improve landing page (#442)
* Add intro screen

* Update changelog
2021-10-30 10:46:54 +02:00
442df9d6f8 Release 1.65.0 (#440) 2021-10-25 21:03:36 +02:00
2de0e75cb8 Feature/add user interface for granting and revoking public access (#439)
* Add user interface for granting and revoking public access

* Update changelog
2021-10-25 20:46:36 +02:00
1296f95602 Feature/move data enhancer from data provider to data gathering (#437)
* Move data enhancers from data provider to data enhancer module

* Update changelog
2021-10-24 10:49:17 +02:00
e98dff877a Add link to Visual Studio Code (#436) 2021-10-22 20:20:59 +02:00
964b37af30 Add Visual Studio Code link (#435) 2021-10-22 20:17:33 +02:00
2d89d12d31 Release 1.64.0 (#434) 2021-10-21 22:25:58 +02:00
19e2d54791 Refactor custom cryptocurrencies (#433) 2021-10-21 22:11:12 +02:00
e24e5e1c44 Add custom crypto currencies (#432)
* Add custom cryptocurrencies
2021-10-21 21:20:01 +02:00
a0d4ff7920 Clean up ts-node script (#430)
Co-Authored-By: Davin <52686922+Frozies@users.noreply.github.com>
2021-10-20 22:46:58 +02:00
099ad18aaf refactor data-provider.service.ts to have dynamic list of services (#429)
* Refactor data-provider.service.ts to have dynamic list of services

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-10-20 21:41:40 +02:00
e3cd99f5d2 Release 1.63.0 (#427) 2021-10-19 18:49:11 +02:00
6dea9093ba Feature/add public portfolio (#426)
* Setup public portfolio page

* Update changelog
2021-10-19 18:27:50 +02:00
43104f81d0 Add test data (#424) 2021-10-17 11:56:35 +02:00
6d2e3b6e40 Feature/improve skeleton loader size (#425)
* Improve skeleton loader size

* Update changelog
2021-10-17 11:53:27 +02:00
1d9a31dbb8 Improve styling (#423) 2021-10-17 11:42:27 +02:00
ea0e92220c Release 1.62.0 (#422) 2021-10-17 10:52:08 +02:00
b57301ef50 Feature/extend import validation message (#421)
* Extend import validation message

* Update changelog
2021-10-16 21:23:15 +02:00
67dbc6b014 Release 1.61.0 (#420) 2021-10-15 22:30:44 +02:00
2e5176bacf Feature/extend import by csv files (#419)
* Support import of csv files

* Update changelog
2021-10-15 22:22:45 +02:00
060846023f Release 1.60.0 (#418) 2021-10-13 12:10:12 +02:00
f06a0fbbee Feature/validate duplicate orders for import (#416)
* Validate duplicate orders

* Update changelog
2021-10-13 11:52:04 +02:00
4ab6a1a071 Feature/harmonize page layouts (#417)
* Harmonize page layouts

* Update changelog
2021-10-13 11:51:33 +02:00
93dcbeb6c7 Feature/add validation for import (#415)
* Valid data types
* Maximum number of orders
* Data provider service returns data for the dataSource / symbol pair
2021-10-12 22:19:32 +02:00
b9f0a57522 Bugfix/unregister chartjs plugin datalabels (#414)
* Unregister chartjs-plugin-datalabels

* Update changelog
2021-10-12 09:23:37 +02:00
174c1d1a62 Release 1.59.0 (#413) 2021-10-11 20:35:06 +02:00
f308ae7a13 add sectors and countries for ETFs (#410)
* Update changelog

Co-Authored-By: Valentin Zickner <valentin@coderworks.de>

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2021-10-11 19:32:21 +02:00
a7a6b0608b Add ghostfolio-docker (#411)
Co-Authored-By: psychowood <115389+psychowood@users.noreply.github.com>
2021-10-09 10:45:58 +02:00
15a61b7a20 Feature/improve values of global heat map (#408)
* Convert value

* Update changelog
2021-10-04 21:22:42 +02:00
d1eedf9726 Bugfix/various fixes (#407)
* Fix links

* Update column

* Fix impersonation mode

* Update changelog
2021-10-03 22:04:23 +02:00
30a592b524 Release 1.58.1 (#406) 2021-10-03 10:50:01 +02:00
de94494aa0 Bugfix/fix symbol conversion for yahoo finance (#405)
* Fix symbol conversion for Yahoo Finance

* Update changelog
2021-10-03 10:40:21 +02:00
d3c6788ad5 Release 1.58.0 (#404) 2021-10-02 20:45:02 +02:00
3ec4a73b35 Feature/improve tooltips (#403)
* Improve tooltips

* Update changelog
2021-10-02 20:38:41 +02:00
1050bfa098 Feature/improve yahoo finance symbol conversion (#402)
* Improve symbol conversion

* Update changelog
2021-10-02 10:28:06 +02:00
595ec1d7b4 Feature/upgrade envalid to version 7.2.1 (#401)
* Upgrade envalid

* Update changelog
2021-09-30 21:54:58 +02:00
125 changed files with 2332 additions and 629 deletions

View File

@ -1 +1,2 @@
/dist
/test/import

View File

@ -5,6 +5,119 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added more details to the public page (currencies, sectors, continents and regions)
- Added a `Dockerfile` and documentation to build a _Docker_ image
## 1.66.0 - 30.10.2021
### Changed
- Improved the landing page
- Ordered the granted accesses by type
## 1.65.0 - 25.10.2021
### Added
- Added the user interface for granting and revoking public access to share the portfolio
### Changed
- Moved the data enhancer calls from the data provider (`get()`) to the data gathering service to reduce traffic to 3rd party data providers
- Changed the profile data gathering from every 12 hours to once every weekend
## 1.64.0 - 21.10.2021
### Added
- Added support for more cryptocurrency symbols like _Avalanche_, _Polygon_, _Shiba Inu_ etc.
### Changed
- Changed the data provider service to handle a dynamic list of services
## 1.63.0 - 19.10.2021
### Added
- Added a public page to share the portfolio
### Changed
- Improved the skeleton loader size of the portfolio proportion chart component
### Todo
- Apply data migration (`yarn prisma migrate deploy`)
## 1.62.0 - 17.10.2021
### Added
- Extended the validation message of the import functionality for transactions
## 1.61.0 - 15.10.2021
### Added
- Extended the import functionality for transactions by `csv` files
- Introduced the primary data source
### Changed
- Restricted the file selector of the import functionality for transactions to `csv` and `json`
## 1.60.0 - 13.10.2021
### Added
- Extended the validation of the import functionality for transactions
- Valid data types
- Maximum number of orders
- No duplicate orders
- Data provider service returns data for the `dataSource` / `symbol` pair
### Changed
- Harmonized the page layouts
### Fixed
- Fixed the broken line charts showing value labels
## 1.59.0 - 11.10.2021
### Added
- Added a data enhancer for symbol profile data (countries and sectors) via _Trackinsight_
### Changed
- Changed the values of the global heat map to fixed-point notation
### Fixed
- Fixed the links of cryptocurrency assets in the positions table
- Fixed various values in the impersonation mode which have not been nullified
## 1.58.1 - 03.10.2021
### Fixed
- Fixed an issue in the symbol conversion for _Yahoo Finance_ (for a cryptocurrency with the same code as a currency)
## 1.58.0 - 02.10.2021
### Changed
- Improved the symbol conversion for _Yahoo Finance_: Support for _Solana USD_ (`SOL1-USD`)
- Improved the tooltips of the allocations page
- Upgraded `envalid` from version `7.1.0` to `7.2.1`
## 1.57.0 - 29.09.2021
### Added

51
Dockerfile Normal file
View File

@ -0,0 +1,51 @@
FROM node:14-alpine as builder
# Build application and add additional files
WORKDIR /ghostfolio
# Only add basic files without the application itself to avoid rebuilding
# layers when files (package.json etc.) have not changed
COPY ./CHANGELOG.md CHANGELOG.md
COPY ./LICENSE LICENSE
COPY ./package.json package.json
COPY ./yarn.lock yarn.lock
COPY ./prisma/schema.prisma prisma/schema.prisma
RUN yarn
# See https://github.com/nrwl/nx/issues/6586 for further details
COPY ./decorate-angular-cli.js decorate-angular-cli.js
RUN node decorate-angular-cli.js
COPY ./angular.json angular.json
COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js
COPY ./jest.config.js jest.config.js
COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps
RUN yarn build:all
# Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api
# package.json was generated by the build process, however the original
# yarn.lock needs to be used to ensure the same versions
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
RUN yarn
COPY prisma /ghostfolio/dist/apps/api/prisma
# Overwrite the generated package.json with the original one to ensure having
# all the scripts
COPY package.json /ghostfolio/dist/apps/api
RUN yarn database:generate-typings
# Image to run, copy everything needed from builder
FROM node:14-alpine
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api
EXPOSE 3333
CMD [ "node", "main" ]

View File

@ -34,7 +34,7 @@
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
## Why Ghostfolio?
@ -81,13 +81,44 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
## Getting Started
## Run with Docker
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup Docker Image
Run the following commands to build and start the Docker image:
```bash
docker-compose -f docker/docker-compose-build-local.yml build
docker-compose -f docker/docker-compose-build-local.yml up
```
### Setup Database
Run the following command to setup the database once Ghostfolio is running:
```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn setup:database
```
### Fetch Historical Data
Open http://localhost:3333 in your browser and accomplish these steps:
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
## Development
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- [Docker](https://www.docker.com/products/docker-desktop)
### Setup
@ -101,18 +132,14 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
## Development
Please make sure you have completed the instructions from [_Setup_](#Setup).
### Start server
### Start Server
<ol type="a">
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li>
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <a href="https://code.visualstudio.com">Visual Studio Code</a></li>
<li>Serve: Run <code>yarn start:server</code></li>
</ol>
### Start client
### Start Client
Run `yarn start:client`

View File

@ -35,6 +35,7 @@
},
"configurations": {
"production": {
"generatePackageJson": true,
"optimization": true,
"extractLicenses": true,
"inspect": false,
@ -99,12 +100,12 @@
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./"
"output": "./assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./"
"output": "./assets"
},
{
"glob": "robots.txt",

View File

@ -1,10 +1,29 @@
import { Access } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessModule } from './access.module';
import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
@Controller('access')
export class AccessController {
@ -20,13 +39,69 @@ export class AccessController {
include: {
GranteeUser: true
},
orderBy: { granteeUserId: 'asc' },
where: { userId: this.request.user.id }
});
return accessesWithGranteeUser.map((access) => {
if (access.GranteeUser) {
return {
granteeAlias: access.GranteeUser?.alias,
id: access.id,
type: 'RESTRICTED_VIEW'
};
}
return {
granteeAlias: access.GranteeUser.alias
granteeAlias: 'Public',
id: access.id,
type: 'PUBLIC'
};
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createAccess(
@Body() data: CreateAccessDto
): Promise<AccessModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createAccess
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accessService.createAccess({
User: { connect: { id: this.request.user.id } }
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAccess
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accessService.deleteAccess({
id_userId: {
id,
userId: this.request.user.id
}
});
}
}

View File

@ -5,8 +5,9 @@ import { AccessController } from './access.controller';
import { AccessService } from './access.service';
@Module({
imports: [],
controllers: [AccessController],
exports: [AccessService],
imports: [],
providers: [AccessService, PrismaService]
})
export class AccessModule {}

View File

@ -1,12 +1,23 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Access, Prisma } from '@prisma/client';
@Injectable()
export class AccessService {
public constructor(private readonly prismaService: PrismaService) {}
public async access(
accessWhereInput: Prisma.AccessWhereInput
): Promise<AccessWithGranteeUser | null> {
return this.prismaService.access.findFirst({
include: {
GranteeUser: true
},
where: accessWhereInput
});
}
public async accesses(params: {
include?: Prisma.AccessInclude;
skip?: number;
@ -26,4 +37,18 @@ export class AccessService {
where
});
}
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
return this.prismaService.access.create({
data
});
}
public async deleteAccess(
where: Prisma.AccessWhereUniqueInput
): Promise<Access> {
return this.prismaService.access.delete({
where
});
}
}

View File

@ -0,0 +1 @@
export class CreateAccessDto {}

View File

@ -1,12 +1,9 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@ -14,18 +11,18 @@ import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller';
@Module({
imports: [ExchangeRateDataModule, RedisCacheModule],
imports: [
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule
],
controllers: [CacheController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
PrismaService
]
})
export class CacheModule {}

View File

@ -1,7 +1,11 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Order } from '@prisma/client';
import { IsArray } from 'class-validator';
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
export class ImportDataDto {
@IsArray()
orders: Partial<Order>[];
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })
orders: Order[];
}

View File

@ -42,7 +42,10 @@ export class ImportController {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
{
error: getReasonPhrase(StatusCodes.BAD_REQUEST),
message: [error.message]
},
StatusCodes.BAD_REQUEST
);
}

View File

@ -1,11 +1,17 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
import { isSameDay, parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(private readonly orderService: OrderService) {}
private static MAX_ORDERS_TO_IMPORT = 20;
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
) {}
public async import({
orders,
@ -14,7 +20,10 @@ export class ImportService {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
await this.validateOrders({ orders, userId });
for (const {
accountId,
currency,
dataSource,
date,
@ -25,6 +34,11 @@ export class ImportService {
unitPrice
} of orders) {
await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
currency,
dataSource,
fee,
@ -37,4 +51,53 @@ export class ImportService {
});
}
}
private async validateOrders({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}) {
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
throw new Error('Too many transactions');
}
const existingOrders = await this.orderService.orders({
orderBy: { date: 'desc' },
where: { userId }
});
for (const [
index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of orders.entries()) {
const duplicateOrder = existingOrders.find((order) => {
return (
order.currency === currency &&
order.dataSource === dataSource &&
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
order.fee === fee &&
order.quantity === quantity &&
order.symbol === symbol &&
order.type === type &&
order.unitPrice === unitPrice
);
});
if (duplicateOrder) {
throw new Error(`orders.${index} is a duplicate transaction`);
}
const result = await this.dataProviderService.get([
{ dataSource, symbol }
]);
if (result[symbol] === undefined) {
throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
}
}
}

View File

@ -1,10 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@ -15,6 +12,8 @@ import { InfoService } from './info.service';
@Module({
imports: [
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
@ -23,15 +22,10 @@ import { InfoService } from './info.service';
],
controllers: [InfoController],
providers: [
AlphaVantageService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
InfoService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
PrismaService
]
})
export class InfoModule {}

View File

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
@ -16,6 +17,7 @@ export class InfoService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
@ -60,6 +62,7 @@ export class InfoService {
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions()
};

View File

@ -1,5 +1,5 @@
import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto {
@IsString()
@ -8,7 +8,7 @@ export class CreateOrderDto {
@IsString()
currency: string;
@IsString()
@IsEnum(DataSource, { each: true })
dataSource: DataSource;
@IsISO8601()
@ -23,7 +23,7 @@ export class CreateOrderDto {
@IsString()
symbol: string;
@IsString()
@IsEnum(Type, { each: true })
type: Type;
@IsNumber()

View File

@ -72,14 +72,7 @@ describe('CurrentRateService', () => {
let marketDataService: MarketDataService;
beforeAll(async () => {
dataProviderService = new DataProviderService(
null,
null,
null,
null,
null,
null
);
dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null);
marketDataService = new MarketDataService(null);

View File

@ -2,7 +2,6 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
@ -27,12 +26,15 @@ export class CurrentRateService {
}: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([
{ symbol, dataSource: DataSource.YAHOO }
{
symbol,
dataSource: this.dataProviderService.getPrimaryDataSource()
}
]);
return {
symbol,
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
symbol: symbol
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
};
}

View File

@ -1,3 +1,4 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
hasNotDefinedValuesInObject,
@ -5,9 +6,11 @@ import {
} from '@ghostfolio/api/helper/object.helper';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import {
PortfolioDetails,
PortfolioPerformance,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
@ -39,6 +42,7 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
export class PortfolioController {
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
@ -145,7 +149,11 @@ export class PortfolioController {
}
const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(impersonationId, range);
await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
res.status(StatusCodes.ACCEPTED);
@ -175,8 +183,9 @@ export class PortfolioController {
portfolioPosition.grossPerformance = null;
portfolioPosition.investment =
portfolioPosition.investment / totalInvestment;
portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null;
portfolioPosition.value = portfolioPosition.value / totalValue;
}
for (const [name, { current, original }] of Object.entries(accounts)) {
@ -251,6 +260,65 @@ export class PortfolioController {
return <any>res.json(result);
}
@Get('public/:accessId')
public async getPublic(
@Param('accessId') accessId,
@Res() res: Response
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
id: access.userId
});
if (!access) {
res.status(StatusCodes.NOT_FOUND);
return <any>res.json({ accounts: {}, holdings: {} });
}
let hasDetails = true;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId
);
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
holdings: {}
};
const totalValue = Object.values(holdings)
.filter((holding) => {
return holding.assetClass === 'EQUITY';
})
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user?.Settings?.currency ?? baseCurrency
);
})
.reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
if (portfolioPosition.assetClass === 'EQUITY') {
portfolioPublicDetails.holdings[symbol] = {
allocationCurrent: portfolioPosition.allocationCurrent,
countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency,
name: portfolioPosition.name,
sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue
};
}
}
return <any>res.json(portfolioPublicDetails);
}
@Get('summary')
@UseGuards(AuthGuard('jwt'))
public async getSummary(

View File

@ -1,3 +1,4 @@
import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
@ -18,6 +19,7 @@ import { RulesService } from './rules.service';
@Module({
imports: [
AccessModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,

View File

@ -1,3 +1,5 @@
// TODO ///////////
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -21,7 +23,11 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import {
UNKNOWN_KEY,
baseCurrency,
ghostfolioCashSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
PortfolioDetails,
@ -78,7 +84,7 @@ export class PortfolioService {
public async getInvestments(
aImpersonationId: string
): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -106,7 +112,7 @@ export class PortfolioService {
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -148,11 +154,12 @@ export class PortfolioService {
public async getDetails(
aImpersonationId: string,
aUserId: string,
aDateRange: DateRange = 'max'
): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, aUserId);
const userCurrency = this.request.user.Settings.currency;
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
userCurrency
@ -265,7 +272,7 @@ export class PortfolioService {
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const orders = (await this.orderService.getOrders({ userId })).filter(
(order) => order.symbol === aSymbol
@ -484,7 +491,7 @@ export class PortfolioService {
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -555,7 +562,7 @@ export class PortfolioService {
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -628,8 +635,8 @@ export class PortfolioService {
}
public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId);
const baseCurrency = this.request.user.Settings.currency;
const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { orders, transactionPoints } = await this.getTransactionPoints({
userId
@ -643,7 +650,7 @@ export class PortfolioService {
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
currency
);
portfolioCalculator.setTransactionPoints(transactionPoints);
@ -659,7 +666,7 @@ export class PortfolioService {
const accounts = await this.getAccounts(
orders,
portfolioItemsNow,
baseCurrency,
currency,
userId
);
return {
@ -679,7 +686,7 @@ export class PortfolioService {
accounts
)
],
{ baseCurrency }
{ baseCurrency: currency }
),
currencyClusterRisk: await this.rulesService.evaluate(
[
@ -700,7 +707,7 @@ export class PortfolioService {
currentPositions
)
],
{ baseCurrency }
{ baseCurrency: currency }
),
fees: await this.rulesService.evaluate(
[
@ -710,7 +717,7 @@ export class PortfolioService {
this.getFees(orders)
)
],
{ baseCurrency }
{ baseCurrency: currency }
)
}
};
@ -718,7 +725,7 @@ export class PortfolioService {
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId);
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const performanceInformation = await this.getPerformance(aImpersonationId);
@ -820,7 +827,7 @@ export class PortfolioService {
return { transactionPoints: [], orders: [] };
}
const userCurrency = this.request.user.Settings.currency;
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.dataSource,
@ -920,14 +927,14 @@ export class PortfolioService {
return accounts;
}
private async getUserId(aImpersonationId: string) {
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
aUserId
);
return impersonationUserId || this.request.user.id;
return impersonationUserId || aUserId;
}
private getTotalByType(

View File

@ -18,7 +18,11 @@ export class CronService {
@Cron(CronExpression.EVERY_12_HOURS)
public async runEveryTwelveHours() {
await this.dataGatheringService.gatherProfileData();
await this.exchangeRateDataService.loadCurrencies();
}
@Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() {
await this.dataGatheringService.gatherProfileData();
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CryptocurrencyService } from './cryptocurrency.service';
@Module({
providers: [CryptocurrencyService],
exports: [CryptocurrencyService]
})
export class CryptocurrencyModule {}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
const cryptocurrencies = require('cryptocurrencies');
const customCryptocurrencies = require('./custom-cryptocurrencies.json');
@Injectable()
export class CryptocurrencyService {
private combinedCryptocurrencies: string[];
public constructor() {}
public isCrypto(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
}
private getCryptocurrencies() {
if (!this.combinedCryptocurrencies) {
this.combinedCryptocurrencies = [
...cryptocurrencies.symbols(),
...Object.keys(customCryptocurrencies)
];
}
return this.combinedCryptocurrencies;
}
}

View File

@ -0,0 +1,6 @@
{
"1INCH": "1inch",
"AVAX": "Avalanche",
"MATIC": "Polygon",
"SHIB": "Shiba Inu"
}

View File

@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
@ -9,11 +10,12 @@ import { ExchangeRateDataModule } from './exchange-rate-data.module';
@Module({
imports: [
ConfigurationModule,
DataEnhancerModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule
],
providers: [DataGatheringService],
exports: [DataGatheringService]
exports: [DataEnhancerModule, DataGatheringService]
})
export class DataGatheringModule {}

View File

@ -3,7 +3,7 @@ import {
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
differenceInHours,
@ -18,6 +18,7 @@ import {
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@ -26,6 +27,8 @@ import { PrismaService } from './prisma.service';
export class DataGatheringService {
public constructor(
private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
@ -130,10 +133,28 @@ export class DataGatheringService {
const currentData = await this.dataProviderService.get(dataGatheringItems);
for (const [
symbol,
{ assetClass, assetSubClass, countries, currency, dataSource, name }
] of Object.entries(currentData)) {
for (const [symbol, response] of Object.entries(currentData)) {
for (const dataEnhancer of this.dataEnhancers) {
try {
currentData[symbol] = await dataEnhancer.enhance({
response,
symbol
});
} catch (error) {
console.error(`Failed to enhance data for symbol ${symbol}`, error);
}
}
const {
assetClass,
assetSubClass,
countries,
currency,
dataSource,
name,
sectors
} = currentData[symbol];
try {
await this.prismaService.symbolProfile.upsert({
create: {
@ -143,6 +164,7 @@ export class DataGatheringService {
currency,
dataSource,
name,
sectors,
symbol
},
update: {
@ -150,7 +172,8 @@ export class DataGatheringService {
assetSubClass,
countries,
currency,
name
name,
sectors
},
where: {
dataSource_symbol: {

View File

@ -6,11 +6,11 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@Injectable()
@ -84,6 +84,10 @@ export class AlphaVantageService implements DataProviderInterface {
}
}
public getName(): DataSource {
return DataSource.ALPHA_VANTAGE;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol);

View File

@ -0,0 +1,15 @@
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
import { Module } from '@nestjs/common';
@Module({
exports: ['DataEnhancers', TrackinsightDataEnhancerService],
providers: [
{
inject: [TrackinsightDataEnhancerService],
provide: 'DataEnhancers',
useFactory: (trackinsight) => [trackinsight]
},
TrackinsightDataEnhancerService
]
})
export class DataEnhancerModule {}

View File

@ -0,0 +1,73 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import bent from 'bent';
const getJSON = bent('json');
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com/holdings';
private static countries = require('countries-list/dist/countries.json');
private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples',
'Health Care': 'Healthcare',
'Information Technology': 'Technology'
};
public async enhance({
response,
symbol
}: {
response: IDataProviderResponse;
symbol: string;
}): Promise<IDataProviderResponse> {
if (
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
) {
return response;
}
const holdings = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
).catch(() => {
return getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${
symbol.split('.')[0]
}.json`
);
});
if (!response.countries || response.countries.length === 0) {
response.countries = [];
for (const [name, value] of Object.entries<any>(holdings.countries)) {
let countryCode: string;
for (const [key, country] of Object.entries<any>(
TrackinsightDataEnhancerService.countries
)) {
if (country.name === name) {
countryCode = key;
break;
}
}
response.countries.push({
code: countryCode,
weight: value.weight
});
}
}
if (!response.sectors || response.sectors.length === 0) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight
});
}
}
return Promise.resolve(response);
}
}

View File

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
@ -9,13 +10,33 @@ import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { DataProviderService } from './data-provider.service';
@Module({
imports: [ConfigurationModule, PrismaModule],
imports: [ConfigurationModule, CryptocurrencyModule, PrismaModule],
providers: [
AlphaVantageService,
DataProviderService,
GhostfolioScraperApiService,
RakutenRapidApiService,
YahooFinanceService
YahooFinanceService,
{
inject: [
AlphaVantageService,
GhostfolioScraperApiService,
RakutenRapidApiService,
YahooFinanceService
],
provide: 'DataProviderInterfaces',
useFactory: (
alphaVantageService,
ghostfolioScraperApiService,
rakutenRapidApiService,
yahooFinanceService
) => [
alphaVantageService,
ghostfolioScraperApiService,
rakutenRapidApiService,
yahooFinanceService
]
}
],
exports: [DataProviderService, GhostfolioScraperApiService]
})

View File

@ -1,5 +1,6 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
@ -8,31 +9,19 @@ import {
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns';
import { isEmpty } from 'lodash';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
import {
YahooFinanceService,
convertToYahooFinanceSymbol
} from './yahoo-finance/yahoo-finance.service';
@Injectable()
export class DataProviderService {
public constructor(
private readonly alphaVantageService: AlphaVantageService,
private readonly configurationService: ConfigurationService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
private readonly prismaService: PrismaService,
private readonly rakutenRapidApiService: RakutenRapidApiService,
private readonly yahooFinanceService: YahooFinanceService
) {
this.rakutenRapidApiService?.setPrisma(this.prismaService);
}
@Inject('DataProviderInterfaces')
private readonly dataProviderInterfaces: DataProviderInterface[],
private readonly prismaService: PrismaService
) {}
public async get(items: IDataGatheringItem[]): Promise<{
[symbol: string]: IDataProviderResponse;
@ -42,27 +31,22 @@ export class DataProviderService {
} = {};
for (const item of items) {
if (item.dataSource === DataSource.ALPHA_VANTAGE) {
response[item.symbol] = (
await this.alphaVantageService.get([item.symbol])
)[item.symbol];
} else if (item.dataSource === DataSource.GHOSTFOLIO) {
response[item.symbol] = (
await this.ghostfolioScraperApiService.get([item.symbol])
)[item.symbol];
} else if (item.dataSource === DataSource.RAKUTEN) {
response[item.symbol] = (
await this.rakutenRapidApiService.get([item.symbol])
)[item.symbol];
} else if (item.dataSource === DataSource.YAHOO) {
response[item.symbol] = (
await this.yahooFinanceService.get([
convertToYahooFinanceSymbol(item.symbol)
])
)[item.symbol];
}
const dataProvider = this.getDataProvider(item.dataSource);
response[item.symbol] = (await dataProvider.get([item.symbol]))[
item.symbol
];
}
const promises = [];
for (const symbol of Object.keys(response)) {
const promise = Promise.resolve(response[symbol]);
promises.push(
promise.then((currentResponse) => (response[symbol] = currentResponse))
);
}
await Promise.all(promises);
return response;
}
@ -103,11 +87,13 @@ export class DataProviderService {
});
try {
const queryRaw = `SELECT * FROM "MarketData" WHERE "dataSource" IN ('${dataSources.join(
`','`
)}') AND "symbol" IN ('${symbols.join(
`','`
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`;
const queryRaw = `SELECT *
FROM "MarketData"
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
AND "symbol" IN ('${symbols.join(
`','`
)}') ${granularityQuery} ${rangeQuery}
ORDER BY date;`;
const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRaw(queryRaw);
@ -189,18 +175,16 @@ export class DataProviderService {
};
}
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
}
private getDataProvider(providerName: DataSource) {
switch (providerName) {
case DataSource.ALPHA_VANTAGE:
return this.alphaVantageService;
case DataSource.GHOSTFOLIO:
return this.ghostfolioScraperApiService;
case DataSource.RAKUTEN:
return this.rakutenRapidApiService;
case DataSource.YAHOO:
return this.yahooFinanceService;
default:
throw new Error('No data provider has been found.');
for (const dataProviderInterface of this.dataProviderInterfaces) {
if (dataProviderInterface.getName() === providerName) {
return dataProviderInterface;
}
}
throw new Error('No data provider has been found.');
}
}

View File

@ -12,13 +12,13 @@ import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { format } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
import { ScraperConfig } from './interfaces/scraper-config.interface';
@Injectable()
@ -144,6 +144,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return [];
}
public getName(): DataSource {
return DataSource.GHOSTFOLIO;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}

View File

@ -0,0 +1,11 @@
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
export interface DataEnhancerInterface {
enhance({
response,
symbol
}: {
response: IDataProviderResponse;
symbol: string;
}): Promise<IDataProviderResponse>;
}

View File

@ -1,10 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from './interfaces';
} from '../../interfaces/interfaces';
export interface DataProviderInterface {
canHandle(symbol: string): boolean;
@ -20,5 +21,7 @@ export interface DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>;
getName(): DataSource;
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
}

View File

@ -14,21 +14,20 @@ import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable()
export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
private prismaService: PrismaService;
public constructor(
private readonly configurationService: ConfigurationService
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
) {}
public canHandle(symbol: string) {
@ -134,12 +133,12 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {};
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
public getName(): DataSource {
return DataSource.RAKUTEN;
}
public setPrisma(aPrismaService: PrismaService) {
this.prismaService = aPrismaService;
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}
private async getFearAndGreedIndex(): Promise<{

View File

@ -1,6 +1,7 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCrypto, isCurrency } from '@ghostfolio/common/helper';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
@ -10,12 +11,12 @@ import { countries } from 'countries-list';
import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
import {
IYahooFinanceHistoricalResponse,
IYahooFinancePrice,
@ -26,18 +27,23 @@ import {
export class YahooFinanceService implements DataProviderInterface {
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor() {}
public constructor(
private readonly cryptocurrencyService: CryptocurrencyService
) {}
public canHandle(symbol: string) {
return true;
}
public async get(
aYahooFinanceSymbols: string[]
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aYahooFinanceSymbols.length <= 0) {
if (aSymbols.length <= 0) {
return {};
}
const yahooFinanceSymbols = aSymbols.map((symbol) =>
this.convertToYahooFinanceSymbol(symbol)
);
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
@ -46,12 +52,12 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: IYahooFinanceQuoteResponse;
} = await yahooFinance.quote({
modules: ['price', 'summaryProfile'],
symbols: aYahooFinanceSymbols
symbols: yahooFinanceSymbols
});
for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
// Convert symbols back
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
@ -62,7 +68,8 @@ export class YahooFinanceService implements DataProviderInterface {
dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName),
marketState:
value.price?.marketState === 'REGULAR' || isCrypto(symbol)
value.price?.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCrypto(symbol)
? MarketState.open
: MarketState.closed,
marketPrice: value.price?.regularMarketPrice || 0,
@ -93,6 +100,12 @@ export class YahooFinanceService implements DataProviderInterface {
response[symbol].countries = [{ code, weight: 1 }];
}
} catch {}
if (value.summaryProfile?.sector) {
response[symbol].sectors = [
{ name: value.summaryProfile?.sector, weight: 1 }
];
}
}
// Add url if available
@ -123,7 +136,7 @@ export class YahooFinanceService implements DataProviderInterface {
}
const yahooFinanceSymbols = aSymbols.map((symbol) => {
return convertToYahooFinanceSymbol(symbol);
return this.convertToYahooFinanceSymbol(symbol);
});
try {
@ -143,7 +156,7 @@ export class YahooFinanceService implements DataProviderInterface {
historicalData
)) {
// Convert symbols back
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {};
timeSeries.forEach((timeSerie) => {
@ -162,6 +175,10 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
public getName(): DataSource {
return DataSource.YAHOO;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = [];
@ -214,6 +231,43 @@ export class YahooFinanceService implements DataProviderInterface {
return { items };
}
private convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace('-', '');
return symbol.replace('=X', '');
}
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF -> USDCHF=X
* Cryptocurrency: BTCUSD -> BTC-USD
* DOGEUSD -> DOGE-USD
* SOL1USD -> SOL1-USD
*/
private convertToYahooFinanceSymbol(aSymbol: string) {
if (
(aSymbol.includes('CHF') ||
aSymbol.includes('EUR') ||
aSymbol.includes('USD')) &&
aSymbol.length >= 6
) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCrypto(aSymbol) ||
this.cryptocurrencyService.isCrypto(aSymbol.replace('1', ''))
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace('USD', '-USD');
}
}
return aSymbol;
}
private parseAssetClass(aPrice: IYahooFinancePrice): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
@ -247,31 +301,3 @@ export class YahooFinanceService implements DataProviderInterface {
return aString;
}
}
export const convertFromYahooFinanceSymbol = (aYahooFinanceSymbol: string) => {
const symbol = aYahooFinanceSymbol.replace('-', '');
return symbol.replace('=X', '');
};
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF=X
* Cryptocurrency: BTC-USD
*/
export const convertToYahooFinanceSymbol = (aSymbol: string) => {
if (isCurrency(aSymbol)) {
if (isCrypto(aSymbol)) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
return `${aSymbol.substring(0, aSymbol.length - 3)}-${aSymbol.substring(
aSymbol.length - 3
)}`;
}
return `${aSymbol}=X`;
}
return aSymbol;
};

View File

@ -210,7 +210,7 @@ export class ExchangeRateDataService {
return {
currency1: baseCurrency,
currency2: currency,
dataSource: DataSource.YAHOO,
dataSource: this.dataProviderService.getPrimaryDataSource(),
symbol: `${baseCurrency}${currency}`
};
});

View File

@ -45,6 +45,7 @@ export interface IDataProviderResponse {
marketPrice: number;
marketState: MarketState;
name?: string;
sectors?: { name: string; weight: number }[];
url?: string;
}

View File

@ -52,6 +52,13 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
},
{
path: 'p',
loadChildren: () =>
import('./pages/public/public-page.module').then(
(m) => m.PublicPageModule
)
},
{
path: 'portfolio',
loadChildren: () =>

View File

@ -1,6 +1,6 @@
<header>
<gf-header
class="position-fixed px-2 w-100"
class="position-fixed w-100"
[currentRoute]="currentRoute"
[info]="info"
[user]="user"
@ -28,7 +28,10 @@
<router-outlet></router-outlet>
</main>
<footer *ngIf="!user" class="footer d-flex justify-content-center w-100">
<footer
*ngIf="currentRoute === 'start'"
class="footer d-flex justify-content-center w-100"
>
<div class="container text-center">
<div>
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>

View File

@ -1,18 +1,52 @@
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="granteeAlias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>User</th>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.granteeAlias }}
</td></ng-container
>
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
Restricted View
</td></ng-container
>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container>
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
Restricted View
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container *ngIf="element.type === 'PUBLIC'">
<ion-icon class="mr-1" name="link-outline"></ion-icon>
<a href="{{ baseUrl }}/p/{{ element.id }}" target="_blank"
>{{ baseUrl }}/p/{{ element.id }}</a
>
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onDeleteAccess(element.id)">
Revoke
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>

View File

@ -2,4 +2,12 @@
:host {
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}

View File

@ -1,9 +1,11 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnInit
OnInit,
Output
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { Access } from '@ghostfolio/common/interfaces';
@ -16,17 +18,37 @@ import { Access } from '@ghostfolio/common/interfaces';
})
export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[];
@Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>();
public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>;
public displayedColumns = ['granteeAlias', 'type'];
public displayedColumns = [];
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['granteeAlias', 'type', 'details'];
if (this.showActions) {
this.displayedColumns.push('actions');
}
if (this.accesses) {
this.dataSource = new MatTableDataSource(this.accesses);
}
}
public onDeleteAccess(aId: string) {
const confirmation = confirm(
'Do you really want to revoke this granted access?'
);
if (confirmation) {
this.accessDeleted.emit(aId);
}
}
}

View File

@ -1,5 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { AccessTableComponent } from './access-table.component';
@ -7,7 +9,7 @@ import { AccessTableComponent } from './access-table.component';
@NgModule({
declarations: [AccessTableComponent],
exports: [AccessTableComponent],
imports: [CommonModule, MatTableModule],
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -51,7 +51,7 @@
<ng-container matColumnDef="balance">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
Balance
Cash Balance
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value

View File

@ -1,6 +1,10 @@
<mat-toolbar class="p-0">
<mat-toolbar class="px-2">
<ng-container *ngIf="user">
<a [routerLink]="['/']" class="no-min-width px-2" mat-button>
<a
[routerLink]="['/']"
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0"
mat-button
>
<gf-logo></gf-logo>
</a>
<span class="spacer"></span>
@ -221,12 +225,11 @@
</ng-container>
<ng-container *ngIf="user === null">
<a
*ngIf="currentRoute && currentRoute !== 'start'"
class="mx-2 no-min-width px-2"
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0"
mat-button
[routerLink]="['/']"
>
<gf-logo></gf-logo>
<gf-logo [hideName]="!currentRoute || currentRoute === 'start'"></gf-logo>
</a>
<span class="spacer"></span>
<a

View File

@ -83,10 +83,10 @@
*matRowDef="let row; columns: displayedColumns"
mat-row
[ngClass]="{
'cursor-pointer': !ignoreAssetClasses.includes(row.assetClass)
'cursor-pointer': !ignoreAssetSubClasses.includes(row.assetSubClass)
}"
(click)="
!ignoreAssetClasses.includes(row.assetClass) &&
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
onOpenPositionDialog({ symbol: row.symbol })
"
></tr>

View File

@ -42,7 +42,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
public dataSource: MatTableDataSource<PortfolioPosition> =
new MatTableDataSource();
public displayedColumns = [];
public ignoreAssetClasses = [AssetClass.CASH.toString()];
public ignoreAssetSubClasses = [AssetClass.CASH.toString()];
public isLoading = true;
public pageSize = 7;
public routeQueryParams: Subscription;

View File

@ -18,6 +18,7 @@ import svgMap from 'svgmap';
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() countries: { [code: string]: { name: string; value: number } };
@Input() isInPercent = false;
public isLoading = true;
public svgMapElement;
@ -41,6 +42,27 @@ export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
}
private initialize() {
if (this.isInPercent) {
// Convert value of countries to percentage
let sum = 0;
Object.keys(this.countries).map((country) => {
sum += this.countries[country].value;
});
Object.keys(this.countries).map((country) => {
this.countries[country].value = Number(
((this.countries[country].value * 100) / sum).toFixed(2)
);
});
} else {
// Convert value to fixed-point notation
Object.keys(this.countries).map((country) => {
this.countries[country].value = Number(
this.countries[country].value.toFixed(2)
);
});
}
this.svgMapElement = new svgMap({
colorMax: '#22bdb9',
colorMin: '#c3f1f0',
@ -49,7 +71,7 @@ export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
applyData: 'value',
data: {
value: {
format: `{0} ${this.baseCurrency}`
format: this.isInPercent ? `{0}%` : `{0} ${this.baseCurrency}`
}
},
values: this.countries

View File

@ -18,6 +18,7 @@ export class AuthGuard implements CanActivate {
'/about',
'/de/blog',
'/en/blog',
'/p',
'/pricing',
'/register',
'/resources'

View File

@ -101,7 +101,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
}
}
return throwError('');
return throwError(error);
})
);
}

View File

@ -11,9 +11,10 @@ import { takeUntil } from 'rxjs/operators';
import { environment } from '../../../environments/environment';
@Component({
host: { class: 'mb-5' },
selector: 'gf-about-page',
templateUrl: './about-page.html',
styleUrls: ['./about-page.scss']
styleUrls: ['./about-page.scss'],
templateUrl: './about-page.html'
})
export class AboutPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;

View File

@ -198,7 +198,7 @@
<h3 class="mb-3 text-center" i18n>Changelog</h3>
<mat-card class="changelog">
<mat-card-content>
<markdown [src]="'CHANGELOG.md'"></markdown>
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
</mat-card-content>
</mat-card>
</div>
@ -209,7 +209,7 @@
<h3 class="mb-3 text-center" i18n>License</h3>
<mat-card>
<mat-card-content>
<markdown [src]="'LICENSE'"></markdown>
<markdown [src]="'assets/LICENSE'"></markdown>
</mat-card-content>
</mat-card>
</div>

View File

@ -5,24 +5,31 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
MatSlideToggle,
MatSlideToggleChange
} from '@angular/material/slide-toggle';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DeviceDetectorService } from 'ngx-device-detector';
import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
@Component({
host: { class: 'mb-5' },
selector: 'gf-account-page',
templateUrl: './account-page.html',
styleUrls: ['./account-page.scss']
styleUrls: ['./account-page.scss'],
templateUrl: './account-page.html'
})
export class AccountPageComponent implements OnDestroy, OnInit {
@ViewChild('toggleSignInWithFingerprintEnabledElement')
@ -34,7 +41,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public couponId: string;
public currencies: string[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public deviceType: string;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateAccess: boolean;
public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
@ -49,6 +59,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private stripeService: StripeService,
private userService: UserService,
public webAuthnService: WebAuthnService
@ -64,6 +78,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
permissions.enableSubscription
);
this.hasPermissionToDeleteAccess = hasPermission(
globalPermissions,
permissions.deleteAccess
);
this.price = subscriptions?.[0]?.price;
this.priceId = subscriptions?.[0]?.priceId;
@ -73,6 +92,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateAccess = hasPermission(
this.user.permissions,
permissions.createAccess
);
this.hasPermissionToDeleteAccess = hasPermission(
this.user.permissions,
permissions.deleteAccess
);
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
@ -86,12 +115,22 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
}
});
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateAccessDialog();
}
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.update();
}
@ -135,6 +174,17 @@ export class AccountPageComponent implements OnDestroy, OnInit {
});
}
public onDeleteAccess(aId: string) {
this.dataService
.deleteAccess(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.update();
}
});
}
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked })
@ -174,6 +224,38 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openCreateAccessDialog(): void {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
data: {
access: {
type: 'PUBLIC'
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data: any) => {
const access: CreateAccessDto = data?.access;
if (access) {
this.dataService
.postAccess({})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.update();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private deregisterDevice() {
this.webAuthnService
.deregister()

View File

@ -132,10 +132,26 @@
</mat-card>
</div>
</div>
<div *ngIf="accesses?.length > 0" class="row">
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Granted Access</h3>
<gf-access-table [accesses]="accesses"></gf-access-table>
<gf-access-table
[accesses]="accesses"
[showActions]="hasPermissionToDeleteAccess"
(accessDeleted)="onDeleteAccess($event)"
></gf-access-table>
</div>
</div>
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[routerLink]="[]"
[queryParams]="{ createDialog: true }"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

View File

@ -12,6 +12,7 @@ import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/acce
import { AccountPageRoutingModule } from './account-page-routing.module';
import { AccountPageComponent } from './account-page.component';
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
@NgModule({
declarations: [AccountPageComponent],
@ -20,6 +21,7 @@ import { AccountPageComponent } from './account-page.component';
AccountPageRoutingModule,
CommonModule,
FormsModule,
GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule,
MatButtonModule,
MatCardModule,

View File

@ -2,6 +2,26 @@
color: rgb(var(--dark-primary-text));
display: block;
gf-access-table {
overflow-x: auto;
table {
min-width: 100%;
.mat-row,
.mat-header-row {
width: 100%;
}
}
}
.fab-container {
position: fixed;
right: 2rem;
bottom: 2rem;
z-index: 999;
}
.hint-text {
font-size: 90%;
line-height: 1.2;

View File

@ -0,0 +1,37 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-or-update-access-dialog',
styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html'
})
export class CreateOrUpdateAccessDialog implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor(
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams
) {}
ngOnInit() {}
public onCancel(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,25 @@
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
<h1 i18n mat-dialog-title>Grant access</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.access.type">
<mat-option i18n value="PUBLIC">Public</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!addAccessForm.form.valid"
[mat-dialog-close]="data"
>
Save
</button>
</div>
</form>

View File

@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component';
@NgModule({
declarations: [CreateOrUpdateAccessDialog],
exports: [],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatSelectModule,
ReactiveFormsModule
],
providers: []
})
export class GfCreateOrUpdateAccessDialogModule {}

View File

@ -0,0 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

View File

@ -0,0 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams {
access: Access;
}

View File

@ -16,9 +16,10 @@ import { takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog/create-or-update-account-dialog.component';
@Component({
host: { class: 'mb-5' },
selector: 'gf-accounts-page',
templateUrl: './accounts-page.html',
styleUrls: ['./accounts-page.scss']
styleUrls: ['./accounts-page.scss'],
templateUrl: './accounts-page.html'
})
export class AccountsPageComponent implements OnDestroy, OnInit {
public accounts: AccountModel[];
@ -44,7 +45,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
private router: Router,
private userService: UserService
) {
this.routeQueryParams = route.queryParams
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog']) {

View File

@ -12,8 +12,8 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.account.accountType">
<mat-option value="CASH" i18n>Cash</mat-option>
<mat-option value="SECURITIES" i18n>Securities</mat-option>
<mat-option i18n value="CASH">Cash</mat-option>
<mat-option i18n value="SECURITIES">Securities</mat-option>
</mat-select>
</mat-form-field>
</div>

View File

@ -15,9 +15,10 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'mb-5' },
selector: 'gf-admin-page',
templateUrl: './admin-page.html',
styleUrls: ['./admin-page.scss']
styleUrls: ['./admin-page.scss'],
templateUrl: './admin-page.html'
})
export class AdminPageComponent implements OnDestroy, OnInit {
public dataGatheringInProgress: boolean;

View File

@ -1,7 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'mb-5' },
selector: 'gf-hallo-ghostfolio-page',
styleUrls: ['./hallo-ghostfolio-page.scss'],
templateUrl: './hallo-ghostfolio-page.html'
})
export class HalloGhostfolioPageComponent {}

View File

@ -139,7 +139,7 @@
Thomas von Ghostfolio
</p>
</section>
<section class="my-5">
<section class="mb-4">
<ul class="list-inline">
<li class="h5">
<span class="badge badge-light font-weight-normal mr-2"

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -1,7 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'mb-5' },
selector: 'gf-hello-ghostfolio-page',
styleUrls: ['./hello-ghostfolio-page.scss'],
templateUrl: './hello-ghostfolio-page.html'
})
export class HelloGhostfolioPageComponent {}

View File

@ -134,7 +134,7 @@
Thomas from Ghostfolio
</p>
</section>
<section class="my-5">
<section class="mb-4">
<ul class="list-inline">
<li class="h5">
<span class="badge badge-light font-weight-normal mr-2"

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -36,8 +36,8 @@ import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-home-page',
templateUrl: './home-page.html',
styleUrls: ['./home-page.scss']
styleUrls: ['./home-page.scss'],
templateUrl: './home-page.html'
})
export class HomePageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-create-account-container') get isDemo() {

View File

@ -1,20 +1,19 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { format } from 'date-fns';
import { Subject } from 'rxjs';
@Component({
host: { class: 'mb-5' },
selector: 'gf-landing-page',
templateUrl: './landing-page.html',
styleUrls: ['./landing-page.scss']
styleUrls: ['./landing-page.scss'],
templateUrl: './landing-page.html'
})
export class LandingPageComponent implements OnDestroy, OnInit {
public currentYear = format(new Date(), 'yyyy');
public demoAuthToken: string;
public historicalDataItems: LineChartItem[];
private unsubscribeSubject = new Subject<void>();
@ -22,7 +21,6 @@ export class LandingPageComponent implements OnDestroy, OnInit {
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private router: Router,
private tokenStorageService: TokenStorageService
@ -35,221 +33,6 @@ export class LandingPageComponent implements OnDestroy, OnInit {
const { demoAuthToken } = this.dataService.fetchInfo();
this.demoAuthToken = demoAuthToken;
this.initializeLineChart();
}
public initializeLineChart() {
this.historicalDataItems = [
{
date: '2017-01-01',
value: 2561.60376
},
{
date: '2017-02-01',
value: 2261.60376
},
{
date: '2017-03-01',
value: 2268.68157074
},
{
date: '2017-04-01',
value: 2525.2942
},
{
date: '2017-05-01',
value: 2929.3595107399997
},
{
date: '2017-06-01',
value: 3088.5172438900004
},
{
date: '2017-07-01',
value: 3281.2490946300004
},
{
date: '2017-08-01',
value: 4714.57822537
},
{
date: '2017-09-01',
value: 5717.262455259565
},
{
date: '2017-10-01',
value: 5338.742482334544
},
{
date: '2017-11-01',
value: 6361.263771554509
},
{
date: '2017-12-01',
value: 8373.260491904595
},
{
date: '2018-01-01',
value: 9783.208968191562
},
{
date: '2018-02-01',
value: 7841.2667100173
},
{
date: '2018-03-01',
value: 8582.133039380678
},
{
date: '2018-04-01',
value: 5901.8362986766715
},
{
date: '2018-05-01',
value: 7367.392976151925
},
{
date: '2018-06-01',
value: 6490.164314049853
},
{
date: '2018-07-01',
value: 6365.351621654618
},
{
date: '2018-08-01',
value: 6614.532706931272
},
{
date: '2018-09-01',
value: 6402.052882414409
},
{
date: '2018-10-01',
value: 15270.327917651943
},
{
date: '2018-11-01',
value: 13929.833891940816
},
{
date: '2018-12-01',
value: 12995.832254431414
},
{
date: '2019-01-01',
value: 11792.447013828998
},
{
date: '2019-02-01',
value: 11988.224284346446
},
{
date: '2019-03-01',
value: 13536.39667099519
},
{
date: '2019-04-01',
value: 14301.83740090022
},
{
date: '2019-05-01',
value: 14902.994910520581
},
{
date: '2019-06-01',
value: 15373.418326284132
},
{
date: '2019-07-01',
value: 17545.70705465703
},
{
date: '2019-08-01',
value: 17206.28500223782
},
{
date: '2019-09-01',
value: 17782.445200108898
},
{
date: '2019-10-01',
value: 17050.25875252655
},
{
date: '2019-11-01',
value: 18517.053521416237
},
{
date: '2019-12-01',
value: 17850.649021651363
},
{
date: '2020-01-01',
value: 18817.269786559067
},
{
date: '2020-02-01',
value: 22769.842312027653
},
{
date: '2020-03-01',
value: 23065.56002316582
},
{
date: '2020-04-01',
value: 19738.122641884733
},
{
date: '2020-05-01',
value: 25112.281463810643
},
{
date: '2020-06-01',
value: 28753.054132147452
},
{
date: '2020-07-01',
value: 32207.62827031543
},
{
date: '2020-08-01',
value: 37837.88816828611
},
{
date: '2020-09-01',
value: 50018.740185519295
},
{
date: '2020-10-01',
value: 46593.322295801525
},
{
date: '2020-11-01',
value: 44440.18743231742
},
{
date: '2020-12-01',
value: 57582.23077536893
},
{
date: '2021-01-01',
value: 68823.02446077733
},
{
date: '2021-02-01',
value: 64516.42092139593
},
{
date: '2021-03-01',
value: 82465.97581106682
},
{
date: '2021-03-18',
value: 86666.03082624623
}
];
}
public setToken(aToken: string) {

View File

@ -1,14 +1,13 @@
<div class="intro-container mb-5">
<div class="intro-inner-container mx-auto">
<div class="h-100 intro w-100"></div>
</div>
</div>
<div class="container">
<div class="row">
<div
class="
align-items-center
d-flex
flex-column
justify-content-center
mb-4
w-100
"
class="align-items-center d-flex flex-column justify-content-center w-100"
>
<gf-logo size="large"></gf-logo>
<p class="lead m-0">Wealth Management Software</p>
@ -16,15 +15,7 @@
</div>
<div class="button-container row">
<div
class="
align-items-center
col
d-flex
justify-content-center
position-relative
"
>
<div class="align-items-center col d-flex justify-content-center">
<div class="py-5 text-center">
<a
class="d-inline-block"
@ -46,15 +37,6 @@
<span i18n>Live Demo</span>
</button>
</div>
<gf-line-chart
class="position-absolute"
symbol="Performance"
[historicalDataItems]="historicalDataItems"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>

View File

@ -2,7 +2,6 @@ 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/ui/line-chart/line-chart.module';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { LandingPageRoutingModule } from './landing-page-routing.module';
@ -13,7 +12,6 @@ import { LandingPageComponent } from './landing-page.component';
exports: [],
imports: [
CommonModule,
GfLineChartModule,
GfLogoModule,
LandingPageRoutingModule,
MatButtonModule,

View File

@ -2,17 +2,6 @@
display: block;
.button-container {
aspect-ratio: 16 / 9;
gf-line-chart {
bottom: 0;
left: 0;
opacity: 0.67;
right: 0;
top: 0;
z-index: -1;
}
.mat-stroked-button {
background-color: var(--light-background);
}
@ -23,6 +12,23 @@
height: 2.5rem;
}
}
.intro-container {
background-color: #ffffff;
margin-top: -5rem;
.intro-inner-container {
aspect-ratio: 16 / 9;
max-height: 66vh;
.intro {
background-image: url('/assets/intro.jpg');
background-position: top left;
background-repeat: no-repeat;
background-size: contain;
}
}
}
}
:host-context(.is-dark-theme) {

View File

@ -15,9 +15,10 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'mb-5' },
selector: 'gf-allocations-page',
templateUrl: './allocations-page.html',
styleUrls: ['./allocations-page.scss']
styleUrls: ['./allocations-page.scss'],
templateUrl: './allocations-page.html'
})
export class AllocationsPageComponent implements OnDestroy, OnInit {
public accounts: {
@ -37,13 +38,23 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
{ label: 'Current', value: 'current' }
];
public portfolioDetails: PortfolioDetails;
public positions: { [symbol: string]: any };
public positions: {
[symbol: string]: Pick<
PortfolioPosition,
| 'assetClass'
| 'assetSubClass'
| 'currency'
| 'exchange'
| 'name'
| 'value'
>;
};
public positionsArray: PortfolioPosition[];
public sectors: {
[name: string]: { name: string; value: number };
};
public symbols: {
[name: string]: { name: string; value: number };
[name: string]: { name: string; symbol: string; value: number };
};
public user: User;
@ -121,6 +132,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.symbols = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
symbol: UNKNOWN_KEY,
value: 0
}
};
@ -137,15 +149,29 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
for (const [symbol, position] of Object.entries(
this.portfolioDetails.holdings
)) {
let value = 0;
if (aPeriod === 'original') {
if (this.hasImpersonationId) {
value = position.allocationInvestment;
} else {
value = position.investment;
}
} else {
if (this.hasImpersonationId) {
value = position.allocationCurrent;
} else {
value = position.value;
}
}
this.positions[symbol] = {
value,
assetClass: position.assetClass,
assetSubClass: position.assetSubClass,
currency: position.currency,
exchange: position.exchange,
value:
aPeriod === 'original'
? position.allocationInvestment
: position.allocationCurrent
name: position.name
};
this.positionsArray.push(position);
@ -221,7 +247,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (position.assetClass === AssetClass.EQUITY) {
this.symbols[symbol] = {
name: symbol,
symbol,
name: position.name,
value: aPeriod === 'original' ? position.investment : position.value
};
}

View File

@ -19,7 +19,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="hasImpersonationId"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
[positions]="accounts"
@ -43,7 +43,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['assetClass', 'assetSubClass']"
[locale]="user?.settings?.locale"
[positions]="positions"
@ -67,7 +67,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['currency']"
[locale]="user?.settings?.locale"
[positions]="positions"
@ -90,8 +90,8 @@
<gf-portfolio-proportion-chart
class="mx-auto"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[keys]="['name']"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['symbol']"
[locale]="user?.settings?.locale"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
@ -113,7 +113,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
@ -138,7 +138,7 @@
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
[positions]="continents"
@ -159,9 +159,9 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[keys]="['name']"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="countries"
@ -186,6 +186,7 @@
<gf-world-map-chart
[baseCurrency]="user?.settings?.baseCurrency"
[countries]="countries"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
></gf-world-map-chart>
</mat-card-content>
</mat-card>

View File

@ -10,9 +10,10 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'mb-5' },
selector: 'gf-analysis-page',
templateUrl: './analysis-page.html',
styleUrls: ['./analysis-page.scss']
styleUrls: ['./analysis-page.scss'],
templateUrl: './analysis-page.html'
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
public accounts: {

View File

@ -7,9 +7,10 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'mb-5' },
selector: 'gf-portfolio-page',
templateUrl: './portfolio-page.html',
styleUrls: ['./portfolio-page.scss']
styleUrls: ['./portfolio-page.scss'],
templateUrl: './portfolio-page.html'
})
export class PortfolioPageComponent implements OnDestroy, OnInit {
public hasPermissionForSubscription: boolean;

View File

@ -5,9 +5,10 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'mb-5' },
selector: 'gf-report-page',
templateUrl: './report-page.html',
styleUrls: ['./report-page.scss']
styleUrls: ['./report-page.scss'],
templateUrl: './report-page.html'
})
export class ReportPageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[];

View File

@ -35,4 +35,4 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class CreateOrUpdateTransactionDialogModule {}
export class GfCreateOrUpdateTransactionDialogModule {}

View File

@ -1,5 +1,5 @@
import { User } from '@ghostfolio/common/interfaces';
import { Account, Order } from '@prisma/client';
import { Order } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams {
accountId: string;

View File

@ -0,0 +1,50 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { ImportTransactionDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-import-transaction-dialog',
styleUrls: ['./import-transaction-dialog.scss'],
templateUrl: 'import-transaction-dialog.html'
})
export class ImportTransactionDialog implements OnDestroy {
public details: any[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
@Inject(MAT_DIALOG_DATA) public data: ImportTransactionDialogParams,
public dialogRef: MatDialogRef<ImportTransactionDialog>
) {}
public ngOnInit() {
for (const message of this.data.messages) {
if (message.includes('orders.')) {
let [index] = message.split(' ');
index = index.replace('orders.', '');
[index] = index.split('.');
this.details.push(this.data.orders[index]);
} else {
this.details.push('');
}
}
}
public onCancel(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,36 @@
<gf-dialog-header
mat-dialog-title
title="Import Transactions Error"
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-header>
<div class="flex-grow-1" mat-dialog-content>
<mat-accordion displayMode="flat">
<mat-expansion-panel
*ngFor="let message of data.messages; let i = index"
[disabled]="!details[i]"
>
<mat-expansion-panel-header class="pl-1">
<mat-panel-title>
<div class="d-flex">
<div class="align-items-center d-flex mr-2">
<ion-icon name="warning-outline"></ion-icon>
</div>
<div>{{ message }}</div>
</div>
</mat-panel-title>
</mat-expansion-panel-header>
<pre
*ngIf="details[i]"
class="m-0"
><code>{{ details[i] | json }}</code></pre>
</mat-expansion-panel>
</mat-accordion>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onCancel()"
></gf-dialog-footer>

View File

@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { ImportTransactionDialog } from './import-transaction-dialog.component';
@NgModule({
declarations: [ImportTransactionDialog],
exports: [],
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
MatButtonModule,
MatDialogModule,
MatExpansionModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfImportTransactionDialogModule {}

View File

@ -0,0 +1,16 @@
:host {
display: block;
.mat-expansion-panel {
background: none;
box-shadow: none;
.mat-expansion-panel-header {
color: inherit;
&[aria-disabled='true'] {
cursor: default;
}
}
}
}

View File

@ -0,0 +1,5 @@
export interface ImportTransactionDialogParams {
deviceType: string;
messages: string[];
orders: any[];
}

View File

@ -6,23 +6,27 @@ import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Order as OrderModel } from '@prisma/client';
import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subject, Subscription } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.component';
import { ImportTransactionDialog } from './import-transaction-dialog/import-transaction-dialog.component';
@Component({
host: { class: 'mb-5' },
selector: 'gf-transactions-page',
templateUrl: './transactions-page.html',
styleUrls: ['./transactions-page.scss']
styleUrls: ['./transactions-page.scss'],
templateUrl: './transactions-page.html'
})
export class TransactionsPageComponent implements OnDestroy, OnInit {
public defaultAccountId: string;
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
@ -32,6 +36,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
public transactions: OrderModel[];
public user: User;
private primaryDataSource: DataSource;
private unsubscribeSubject = new Subject<void>();
/**
@ -43,11 +48,15 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute,
private router: Router,
private snackBar: MatSnackBar,
private userService: UserService
) {
const { primaryDataSource } = this.dataService.fetchInfo();
this.primaryDataSource = primaryDataSource;
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
@ -55,8 +64,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.openCreateTransactionDialog();
} else if (params['editDialog']) {
if (this.transactions) {
const transaction = this.transactions.find((transaction) => {
return transaction.id === params['transactionId'];
const transaction = this.transactions.find(({ id }) => {
return id === params['transactionId'];
});
this.openUpdateTransactionDialog(transaction);
@ -93,6 +102,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.defaultAccountId = this.user?.accounts.find((account) => {
return account.isDefault;
})?.id;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
@ -157,9 +170,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
public onImport() {
const input = document.createElement('input');
input.accept = 'application/JSON, .csv';
input.type = 'file';
input.onchange = (event) => {
this.snackBar.open('⏳ Importing data...');
// Getting the file reference
const file = (event.target as HTMLInputElement).files[0];
@ -167,35 +183,51 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
const reader = new FileReader();
reader.readAsText(file, 'UTF-8');
reader.onload = (readerEvent) => {
reader.onload = async (readerEvent) => {
const fileContent = readerEvent.target.result as string;
try {
const content = JSON.parse(readerEvent.target.result as string);
if (file.type === 'application/json') {
const content = JSON.parse(fileContent);
try {
await this.importTransactionsService.importJson({
content: content.orders,
defaultAccountId: this.defaultAccountId
});
this.snackBar.open('⏳ Importing data...');
this.handleImportSuccess();
} catch (error) {
this.handleImportError({ error, orders: content.orders });
}
this.dataService
.postImport({
orders: content.orders
})
.pipe(
catchError((error) => {
this.handleImportError(error);
return;
} else if (file.type === 'text/csv') {
try {
await this.importTransactionsService.importCsv({
fileContent,
defaultAccountId: this.defaultAccountId,
primaryDataSource: this.primaryDataSource
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe({
next: () => {
this.fetchOrders();
this.handleImportSuccess();
} catch (error) {
this.handleImportError({
error: {
error: { message: error?.error?.message ?? [error?.message] }
},
orders: error?.orders ?? []
});
}
this.snackBar.open('✅ Import has been completed', undefined, {
duration: 3000
});
}
});
return;
}
throw new Error();
} catch (error) {
this.handleImportError(error);
this.handleImportError({
error: { error: { message: ['Unexpected format'] } },
orders: []
});
}
};
};
@ -281,20 +313,32 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
a.click();
}
private handleImportError(aError: unknown) {
console.error(aError);
this.snackBar.open('❌ Oops, something went wrong...');
private handleImportError({ error, orders }: { error: any; orders: any[] }) {
this.snackBar.dismiss();
this.dialog.open(ImportTransactionDialog, {
data: {
orders,
deviceType: this.deviceType,
messages: error?.error?.message
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
}
private handleImportSuccess() {
this.fetchOrders();
this.snackBar.open('✅ Import has been completed', undefined, {
duration: 3000
});
}
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: {
transaction: {
accountId:
aTransaction?.accountId ??
this.user?.accounts.find((account) => {
return account.isDefault;
})?.id,
accountId: aTransaction?.accountId ?? this.defaultAccountId,
currency: aTransaction?.currency ?? null,
dataSource: aTransaction?.dataSource ?? null,
date: new Date(),

View File

@ -4,8 +4,10 @@ import { MatButtonModule } from '@angular/material/button';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { CreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
import { GfCreateOrUpdateTransactionDialogModule } from './create-or-update-transaction-dialog/create-or-update-transaction-dialog.module';
import { GfImportTransactionDialogModule } from './import-transaction-dialog/import-transaction-dialog.module';
import { TransactionsPageRoutingModule } from './transactions-page-routing.module';
import { TransactionsPageComponent } from './transactions-page.component';
@ -14,14 +16,15 @@ import { TransactionsPageComponent } from './transactions-page.component';
exports: [],
imports: [
CommonModule,
CreateOrUpdateTransactionDialogModule,
GfCreateOrUpdateTransactionDialogModule,
GfImportTransactionDialogModule,
GfTransactionsTableModule,
MatButtonModule,
MatSnackBarModule,
RouterModule,
TransactionsPageRoutingModule
],
providers: [],
providers: [ImportTransactionsService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class TransactionsPageModule {}

View File

@ -7,9 +7,10 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'mb-5' },
selector: 'gf-pricing-page',
templateUrl: './pricing-page.html',
styleUrls: ['./pricing-page.scss']
styleUrls: ['./pricing-page.scss'],
templateUrl: './pricing-page.html'
})
export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;

View File

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

View File

@ -0,0 +1,184 @@
import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
PortfolioPosition,
PortfolioPublicDetails
} from '@ghostfolio/common/interfaces';
import { StatusCodes } from 'http-status-codes';
import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'mb-5' },
selector: 'gf-public-page',
styleUrls: ['./public-page.scss'],
templateUrl: './public-page.html'
})
export class PublicPageComponent implements OnInit {
public continents: {
[code: string]: { name: string; value: number };
};
public countries: {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public portfolioPublicDetails: PortfolioPublicDetails;
public positions: {
[symbol: string]: Pick<PortfolioPosition, 'currency' | 'name' | 'value'>;
};
public sectors: {
[name: string]: { name: string; value: number };
};
public symbols: {
[name: string]: { name: string; symbol: string; value: number };
};
private id: string;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private activatedRoute: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private router: Router
) {
this.activatedRoute.params.subscribe((params) => {
this.id = params['id'];
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService
.fetchPortfolioPublic(this.id)
.pipe(
takeUntil(this.unsubscribeSubject),
catchError((error) => {
if (error.status === StatusCodes.NOT_FOUND) {
console.error(error);
this.router.navigate(['/']);
}
return EMPTY;
})
)
.subscribe((portfolioPublicDetails) => {
this.portfolioPublicDetails = portfolioPublicDetails;
this.initializeAnalysisData();
this.changeDetectorRef.markForCheck();
});
}
public initializeAnalysisData() {
this.continents = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
this.countries = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
this.positions = {};
this.sectors = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
this.symbols = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
symbol: UNKNOWN_KEY,
value: 0
}
};
for (const [symbol, position] of Object.entries(
this.portfolioPublicDetails.holdings
)) {
const value = position.allocationCurrent;
this.positions[symbol] = {
value,
currency: position.currency,
name: position.name
};
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 * this.portfolioPublicDetails.holdings[symbol].value
};
}
if (this.countries[code]?.value) {
this.countries[code].value += weight * position.value;
} else {
this.countries[code] = {
name,
value: weight * this.portfolioPublicDetails.holdings[symbol].value
};
}
}
} else {
this.continents[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].value;
this.countries[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].value;
}
if (position.sectors.length > 0) {
for (const sector of position.sectors) {
const { name, weight } = sector;
if (this.sectors[name]?.value) {
this.sectors[name].value += weight * position.value;
} else {
this.sectors[name] = {
name,
value: weight * this.portfolioPublicDetails.holdings[symbol].value
};
}
}
} else {
this.sectors[UNKNOWN_KEY].value +=
this.portfolioPublicDetails.holdings[symbol].value;
}
this.symbols[symbol] = {
symbol,
name: position.name,
value: position.value
};
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,102 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="h4 mb-3 text-center" i18n>
Hello, someone has shared a <strong>Portfolio</strong> with you!
</h3>
</div>
</div>
<div class="proportion-charts row">
<div class="col-md-12 allocations-by-symbol">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>Symbols</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
class="mx-auto"
[isInPercent]="true"
[keys]="['symbol']"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div *ngIf="portfolioPublicDetails?.hasDetails" class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>Currencies</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[isInPercent]="true"
[keys]="['currency']"
[maxItems]="10"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div *ngIf="portfolioPublicDetails?.hasDetails" class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>Sectors</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[isInPercent]="true"
[keys]="['name']"
[maxItems]="10"
[positions]="sectors"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div *ngIf="portfolioPublicDetails?.hasDetails" class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>Continents</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[isInPercent]="true"
[keys]="['name']"
[positions]="continents"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
</div>
<div *ngIf="portfolioPublicDetails?.hasDetails" class="row world-map-chart">
<div class="col-lg">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>Regions</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-world-map-chart
[countries]="countries"
[isInPercent]="true"
></gf-world-map-chart>
</mat-card-content>
</mat-card>
</div>
</div>
<div class="row my-5">
<div class="col-md-8 offset-md-2">
<h2 class="h4 mb-1 text-center">
Would you like to <strong>refine</strong> your
<strong>personal investment strategy</strong>?
</h2>
<p class="lead mb-3 text-center" i18n>
Ghostfolio empowers you to keep track of your wealth.
</p>
<div class="py-2 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/']">
Get Started
</a>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { PublicPageRoutingModule } from './public-page-routing.module';
import { PublicPageComponent } from './public-page.component';
@NgModule({
declarations: [PublicPageComponent],
exports: [],
imports: [
CommonModule,
GfPortfolioProportionChartModule,
GfWorldMapChartModule,
MatButtonModule,
MatCardModule,
PublicPageRoutingModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class PublicPageModule {}

View File

@ -0,0 +1,22 @@
:host {
color: rgb(var(--dark-primary-text));
display: block;
.allocations-by-symbol {
gf-portfolio-proportion-chart {
max-width: 80vh;
}
}
.world-map-chart {
.mat-card {
.mat-card-content {
aspect-ratio: 16 / 9;
}
}
}
}
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
}

View File

@ -12,9 +12,10 @@ import { takeUntil } from 'rxjs/operators';
import { ShowAccessTokenDialog } from './show-access-token-dialog/show-access-token-dialog.component';
@Component({
host: { class: 'mb-5' },
selector: 'gf-register-page',
templateUrl: './register-page.html',
styleUrls: ['./register-page.scss']
styleUrls: ['./register-page.scss'],
templateUrl: './register-page.html'
})
export class RegisterPageComponent implements OnDestroy, OnInit {
public currentYear = format(new Date(), 'yyyy');

View File

@ -2,9 +2,10 @@ import { Component, OnInit } from '@angular/core';
import { Subject } from 'rxjs';
@Component({
host: { class: 'mb-5' },
selector: 'gf-resources-page',
templateUrl: './resources-page.html',
styleUrls: ['./resources-page.scss']
styleUrls: ['./resources-page.scss'],
templateUrl: './resources-page.html'
})
export class ResourcesPageComponent implements OnInit {
private unsubscribeSubject = new Subject<void>();

View File

@ -6,9 +6,10 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'mb-5' },
selector: 'gf-webauthn-page',
templateUrl: './webauthn-page.html',
styleUrls: ['./webauthn-page.scss']
styleUrls: ['./webauthn-page.scss'],
templateUrl: './webauthn-page.html'
})
export class WebauthnPageComponent implements OnDestroy, OnInit {
public hasError = false;

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