Compare commits

..

19 Commits

Author SHA1 Message Date
ba926ffcf2 Release 1.52.0 (#366) 2021-09-11 21:36:22 +02:00
5ea455b98b Feature/upgrade simplewebauthn dependencies to version 4.1.0 (#365)
* Upgrade @simplewebauthn dependencies to version 4.1.0
  * @simplewebauthn/browser
  * @simplewebauthn/server

* Update changelog
2021-09-11 21:23:06 +02:00
39f315aba0 Feature/add annualized performance (#364)
* Add annualized performance

* Update changelog
2021-09-11 20:16:53 +02:00
df2dfc20a1 Feature/add slack channel (#363)
* Add Slack channel

* Update changelog
2021-09-11 12:06:28 +02:00
81e83d4cea Release 1.51.0 (#362) 2021-09-11 11:25:07 +02:00
5d4156ecec Feature/refactor position detail dialog (#355)
* Add name to portfolio position endpoint

* Update changelog
2021-09-11 11:23:47 +02:00
4693a8baa2 Release 1.50.0 (#361) 2021-09-11 11:21:53 +02:00
773444b1e2 Bugfix/fix home button overlap on ios (#360)
* Fix overlap

* Update changelog
2021-09-11 11:17:49 +02:00
3c46bde8d5 Bugfix/fix fear and greed index (#359)
* Fix fear and greed index
* Refactor fear and greed index symbol
   * GF.FEAR_AND_GREED_INDEX -> _GF_FEAR_AND_GREED_INDEX

* Update changelog
2021-09-11 11:14:55 +02:00
63ee33b685 Use 'import type' to import types, eliminate webpack warnings (#358) 2021-09-11 09:27:22 +02:00
bc87c0a3e1 Add slack (#357) 2021-09-10 18:11:35 +02:00
caa9fc3efa Release 1.49.0 (#354) 2021-09-08 22:19:53 +02:00
9ed82ac82b Feature/improve labels of allocation chart by symbol (#353)
* Improve labels

* Update changelog
2021-09-08 22:03:33 +02:00
9c9ca4ab1e Add labels to allocation piecharts (#337) 2021-09-08 21:31:34 +02:00
b0b0942162 Release 1.48.0 (#352)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:23:07 +02:00
9cbf789c22 Bugfix/fix values in position detail dialog (#351)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:11:38 +02:00
ee5ab05d8a Release 1.47.1 (#350) 2021-09-06 22:55:08 +02:00
20731c67cb Release 1.47.0 (#349) 2021-09-06 22:34:17 +02:00
bf8856ad19 Bugfix/fix search for cryptocurrencies (#348)
* Fix the search for cryptocurrency symbols

* Update changelog
2021-09-06 22:02:49 +02:00
52 changed files with 433 additions and 239 deletions

View File

@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.52.0 - 11.09.2021
### Added
- Added the annualized performance to the portfolio summary tab on the home page
- Added the Ghostfolio Slack channel to the about page
### Changed
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `3.0.0` to `4.1.0`
### Fixed
- Fixed the sign in with fingerprint for some android devices
## 1.51.0 - 11.09.2021
### Changed
- Provided the name in the portfolio position endpoint
## 1.50.0 - 11.09.2021
### Fixed
- Fixed the _Fear & Greed Index_ (market mood)
- Fixed the overlap of the home button with tabs on iOS (_Add to Home Screen_)
## 1.49.0 - 08.09.2021
### Added
- Added labels to the allocation chart by symbol on desktop
## 1.48.0 - 07.09.2021
### Added
- Added the attribute `precision` in the value component
### Fixed
- Hid the performance in the _Presenter View_
## 1.47.1 - 06.09.2021
### Fixed
- Fixed the search functionality for cryptocurrency symbols
## 1.46.0 - 05.09.2021
### Added

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p>
<p>
<a href="#contributing">
@ -128,7 +128,7 @@ Run `yarn test`
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
## License

View File

@ -1,5 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';

View File

@ -6,7 +6,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -5,7 +5,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,

View File

@ -4,7 +4,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,

View File

@ -62,10 +62,10 @@ export class AuthController {
}
}
@Get('webauthn/generate-attestation-options')
@Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'))
public async generateAttestationOptions() {
return this.webAuthService.generateAttestationOptions();
public async generateRegistrationOptions() {
return this.webAuthService.generateRegistrationOptions();
}
@Post('webauthn/verify-attestation')

View File

@ -2,7 +2,7 @@ import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Inject,
Injectable,
@ -11,16 +11,16 @@ import {
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import {
GenerateAssertionOptionsOpts,
GenerateAttestationOptionsOpts,
VerifiedAssertion,
VerifiedAttestation,
VerifyAssertionResponseOpts,
VerifyAttestationResponseOpts,
generateAssertionOptions,
generateAttestationOptions,
verifyAssertionResponse,
verifyAttestationResponse
GenerateAuthenticationOptionsOpts,
GenerateRegistrationOptionsOpts,
VerifiedAuthenticationResponse,
VerifiedRegistrationResponse,
VerifyAuthenticationResponseOpts,
VerifyRegistrationResponseOpts,
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse
} from '@simplewebauthn/server';
import {
@ -46,10 +46,10 @@ export class WebAuthService {
return this.configurationService.get('ROOT_URL');
}
public async generateAttestationOptions() {
public async generateRegistrationOptions() {
const user = this.request.user;
const opts: GenerateAttestationOptionsOpts = {
const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
@ -63,7 +63,7 @@ export class WebAuthService {
}
};
const options = generateAttestationOptions(opts);
const options = generateRegistrationOptions(opts);
await this.userService.updateUser({
data: {
@ -84,27 +84,27 @@ export class WebAuthService {
const user = this.request.user;
const expectedChallenge = user.authChallenge;
let verification: VerifiedAttestation;
let verification: VerifiedRegistrationResponse;
try {
const opts: VerifyAttestationResponseOpts = {
const opts: VerifyRegistrationResponseOpts = {
credential,
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
};
verification = await verifyAttestationResponse(opts);
verification = await verifyRegistrationResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException(error.message);
}
const { verified, attestationInfo } = verification;
const { registrationInfo, verified } = verification;
const devices = await this.deviceService.authDevices({
where: { userId: user.id }
});
if (verified && attestationInfo) {
const { credentialPublicKey, credentialID, counter } = attestationInfo;
if (registrationInfo && verified) {
const { counter, credentialID, credentialPublicKey } = registrationInfo;
let existingDevice = devices.find(
(device) => device.credentialId === credentialID
@ -115,9 +115,9 @@ export class WebAuthService {
* Add the returned device to the user's list of devices
*/
existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialPublicKey,
credentialId: credentialID,
counter,
User: { connect: { id: user.id } }
});
}
@ -138,20 +138,20 @@ export class WebAuthService {
throw new Error('Device not found');
}
const opts: GenerateAssertionOptionsOpts = {
timeout: 60000,
const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [
{
id: device.credentialId,
type: 'public-key',
transports: ['internal']
transports: ['internal'],
type: 'public-key'
}
],
userVerification: 'preferred',
rpID: this.rpID
rpID: this.rpID,
timeout: 60000,
userVerification: 'preferred'
};
const options = generateAssertionOptions(opts);
const options = generateAuthenticationOptions(opts);
await this.userService.updateUser({
data: {
@ -177,29 +177,29 @@ export class WebAuthService {
const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion;
let verification: VerifiedAuthenticationResponse;
try {
const opts: VerifyAssertionResponseOpts = {
const opts: VerifyAuthenticationResponseOpts = {
credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
counter: device.counter
}
},
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
};
verification = verifyAssertionResponse(opts);
verification = verifyAuthenticationResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException({ error: error.message });
}
const { verified, assertionInfo } = verification;
const { verified, authenticationInfo } = verification;
if (verified) {
device.counter = assertionInfo.newCounter;
device.counter = authenticationInfo.newCounter;
await this.deviceService.updateAuthDevice({
data: device,

View File

@ -1,6 +1,6 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';

View File

@ -1,7 +1,7 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -1,5 +1,5 @@
import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -6,7 +6,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -11,6 +11,7 @@ export interface PortfolioPositionDetail {
marketPrice: number;
maxPrice: number;
minPrice: number;
name: string;
netPerformance: number;
netPerformancePercent: number;
quantity: number;

View File

@ -11,7 +11,7 @@ import {
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -276,6 +276,7 @@ export class PortfolioController {
position = nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'netPerformance',
'quantity'
]);
}

View File

@ -0,0 +1,62 @@
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
let portfolioService: PortfolioService;
beforeAll(async () => {
portfolioService = new PortfolioService(
null,
null,
null,
null,
null,
null,
null,
null,
null
);
});
it('Get annualized performance', async () => {
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: 0
})
).toEqual(0);
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercent: 0
})
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercent: 0.1025
})
).toBeCloseTo(0.729705);
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercent: 0.05
})
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercent: 0.2374
})
).toBeCloseTo(0.145);
});
});

View File

@ -32,7 +32,7 @@ import {
TimelinePosition
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
import type {
DateRange,
OrderWithAccount,
RequestWithUser
@ -47,6 +47,7 @@ import {
} from '@prisma/client';
import Big from 'big.js';
import {
differenceInDays,
endOfToday,
format,
isAfter,
@ -58,7 +59,7 @@ import {
subDays,
subYears
} from 'date-fns';
import { isEmpty } from 'lodash';
import { isEmpty, isNumber } from 'lodash';
import {
HistoricalDataItem,
@ -80,6 +81,21 @@ export class PortfolioService {
private readonly symbolProfileService: SymbolProfileService
) {}
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
}: {
daysInMarket: number;
netPerformancePercent: number;
}) {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return Math.pow(1 + netPerformancePercent, exponent) - 1;
}
return 0;
}
public async getInvestments(
aImpersonationId: string
): Promise<InvestmentItem[]> {
@ -282,6 +298,7 @@ export class PortfolioService {
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
name: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: undefined,
@ -291,6 +308,7 @@ export class PortfolioService {
}
const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? '';
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
@ -407,6 +425,7 @@ export class PortfolioService {
marketPrice,
maxPrice,
minPrice,
name,
netPerformance,
transactionCount,
averagePrice: averagePrice.toNumber(),
@ -455,6 +474,7 @@ export class PortfolioService {
marketPrice,
maxPrice,
minPrice,
name,
averagePrice: 0,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
@ -711,6 +731,12 @@ export class PortfolioService {
const fees = this.getFees(orders);
const firstOrderDate = orders[0]?.date;
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), firstOrderDate),
netPerformancePercent:
performanceInformation.performance.currentNetPerformancePercent
});
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
@ -722,6 +748,7 @@ export class PortfolioService {
return {
...performanceInformation.performance,
annualizedPerformancePercent,
fees,
firstOrderDate,
netWorth,

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,

View File

@ -17,7 +17,7 @@ export class SymbolService {
const response = await this.dataProviderService.get([aSymbol]);
const { currency, dataSource, marketPrice } = response[aSymbol] ?? {};
if (currency && dataSource && marketPrice) {
if (dataSource && marketPrice) {
return {
dataSource,
marketPrice,

View File

@ -4,7 +4,7 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,

View File

@ -1,4 +1,8 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import {
benchmarks,
currencyPairs,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getUtc,
@ -295,7 +299,7 @@ export class DataGatheringService {
benchmarksToGather.push({
dataSource: DataSource.RAKUTEN,
date: startDate,
symbol: 'GF.FEAR_AND_GREED_INDEX'
symbol: ghostfolioFearAndGreedIndexSymbol
});
}

View File

@ -19,7 +19,10 @@ import { format } from 'date-fns';
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 } from './yahoo-finance/yahoo-finance.service';
import {
convertToYahooFinanceSymbol,
YahooFinanceService
} from './yahoo-finance/yahoo-finance.service';
@Injectable()
export class DataProviderService {
@ -47,12 +50,16 @@ export class DataProviderService {
}
}
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
return (
!isGhostfolioScraperApiSymbol(symbol) &&
!isRakutenRapidApiSymbol(symbol)
);
});
const yahooFinanceSymbols = aSymbols
.filter((symbol) => {
return (
!isGhostfolioScraperApiSymbol(symbol) &&
!isRakutenRapidApiSymbol(symbol)
);
})
.map((symbol) => {
return convertToYahooFinanceSymbol(symbol);
});
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);

View File

@ -1,6 +1,7 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getToday,
@ -47,11 +48,11 @@ export class RakutenRapidApiService implements DataProviderInterface {
try {
const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') {
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
return {
'GF.FEAR_AND_GREED_INDEX': {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: DataSource.RAKUTEN,
marketPrice: fgi.now.value,
@ -82,7 +83,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
try {
const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') {
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
try {
@ -118,7 +119,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
} catch {}
return {
'GF.FEAR_AND_GREED_INDEX': {
[ghostfolioFearAndGreedIndexSymbol]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value
}

View File

@ -43,16 +43,12 @@ export class YahooFinanceService implements DataProviderInterface {
}
public async get(
aSymbols: string[]
aYahooFinanceSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
if (aYahooFinanceSymbols.length <= 0) {
return {};
}
const yahooSymbols = aSymbols.map((symbol) => {
return this.convertToYahooSymbol(symbol);
});
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
@ -60,12 +56,12 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: IYahooFinanceQuoteResponse;
} = await yahooFinance.quote({
modules: ['price', 'summaryProfile'],
symbols: yahooSymbols
symbols: aYahooFinanceSymbols
});
for (const [yahooSymbol, value] of Object.entries(data)) {
for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
// Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol);
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
@ -136,15 +132,15 @@ export class YahooFinanceService implements DataProviderInterface {
return {};
}
const yahooSymbols = aSymbols.map((symbol) => {
return this.convertToYahooSymbol(symbol);
const yahooFinanceSymbols = aSymbols.map((symbol) => {
return convertToYahooFinanceSymbol(symbol);
});
try {
const historicalData: {
[symbol: string]: IYahooFinanceHistoricalResponse[];
} = await yahooFinance.historical({
symbols: yahooSymbols,
symbols: yahooFinanceSymbols,
from: format(from, DATE_FORMAT),
to: format(to, DATE_FORMAT)
});
@ -153,9 +149,11 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
for (const [yahooSymbol, timeSeries] of Object.entries(historicalData)) {
for (const [yahooFinanceSymbol, timeSeries] of Object.entries(
historicalData
)) {
// Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol);
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {};
timeSeries.forEach((timeSerie) => {
@ -175,7 +173,7 @@ export class YahooFinanceService implements DataProviderInterface {
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
const items: LookupItem[] = [];
try {
const get = bent(
@ -192,19 +190,6 @@ export class YahooFinanceService implements DataProviderInterface {
// filter out undefined symbols
return quote.symbol;
})
.filter(({ quoteType }) => {
return quoteType === 'EQUITY' || quoteType === 'ETF';
})
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
items = searchResult.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
.filter(({ quoteType }) => {
return (
quoteType === 'CRYPTOCURRENCY' ||
@ -220,42 +205,25 @@ export class YahooFinanceService implements DataProviderInterface {
return true;
})
.map(({ longname, shortname, symbol }) => {
return {
currency: marketData[symbol]?.currency,
dataSource: DataSource.YAHOO,
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
for (const [symbol, value] of Object.entries(marketData)) {
items.push({
symbol,
currency: value.currency,
dataSource: DataSource.YAHOO,
name: value.name
});
}
} catch {}
return { items };
}
/**
* Converts a symbol to a Yahoo symbol
*
* Currency: USDCHF=X
* Cryptocurrency: BTC-USD
*/
private convertToYahooSymbol(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;
}
private parseAssetClass(aPrice: IYahooFinancePrice): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
@ -290,7 +258,30 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
export const convertFromYahooSymbol = (aSymbol: string) => {
const symbol = aSymbol.replace('-', '');
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

@ -16,6 +16,7 @@ import { LinearScale } from 'chart.js';
import { ArcElement } from 'chart.js';
import { DoughnutController } from 'chart.js';
import { Chart } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import * as Color from 'color';
@Component({
@ -32,6 +33,7 @@ export class PortfolioProportionChartComponent
@Input() keys: string[];
@Input() locale: string;
@Input() maxItems?: number;
@Input() showLabels = false;
@Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
};
@ -48,7 +50,13 @@ export class PortfolioProportionChartComponent
};
public constructor() {
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
Chart.register(
ArcElement,
ChartDataLabels,
DoughnutController,
LinearScale,
Tooltip
);
}
public ngOnInit() {}
@ -235,7 +243,30 @@ export class PortfolioProportionChartComponent
data,
options: {
cutout: '70%',
layout: {
padding: this.showLabels === true ? 100 : 0
},
plugins: {
datalabels: {
color: (context) => {
return this.getColorPalette()[
context.dataIndex % this.getColorPalette().length
];
},
display: this.showLabels === true ? 'auto' : false,
labels: {
index: {
align: 'end',
anchor: 'end',
formatter: (value, context) => {
return value > 0
? context.chart.data.labels[context.dataIndex]
: '';
},
offset: 8
}
}
},
legend: { display: false },
tooltip: {
callbacks: {

View File

@ -146,7 +146,7 @@
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Net Worth</div>
<div class="d-flex flex-grow-1 font-weight-bold" i18n>Net Worth</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
@ -156,4 +156,17 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Annualized Performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : summary?.annualizedPerformancePercent"
></gf-value>
</div>
</div>
</div>

View File

@ -3,5 +3,4 @@ export interface PositionDetailDialogParams {
deviceType: string;
locale: string;
symbol: string;
title: string;
}

View File

@ -34,9 +34,11 @@ export class PositionDetailDialog implements OnDestroy {
public marketPrice: number;
public maxPrice: number;
public minPrice: number;
public name: string;
public netPerformance: number;
public netPerformancePercent: number;
public quantity: number;
public symbol: string;
public transactionCount: number;
private unsubscribeSubject = new Subject<void>();
@ -62,9 +64,11 @@ export class PositionDetailDialog implements OnDestroy {
marketPrice,
maxPrice,
minPrice,
name,
netPerformance,
netPerformancePercent,
quantity,
symbol,
transactionCount
}) => {
this.averagePrice = averagePrice;
@ -90,9 +94,11 @@ export class PositionDetailDialog implements OnDestroy {
this.marketPrice = marketPrice;
this.maxPrice = maxPrice;
this.minPrice = minPrice;
this.name = name;
this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent;
this.quantity = quantity;
this.symbol = symbol;
this.transactionCount = transactionCount;
if (isToday(parseISO(this.firstBuyDate))) {

View File

@ -1,7 +1,7 @@
<gf-dialog-header
mat-dialog-title
[deviceType]="data.deviceType"
[title]="data.title ?? data.symbol"
[title]="name ?? symbol"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
@ -80,7 +80,8 @@
<gf-value
label="Quantity"
size="medium"
[isCurrency]="true"
[locale]="data.locale"
[precision]="2"
[value]="quantity"
></gf-value>
</div>
@ -103,8 +104,6 @@
<div class="col-6 mb-3">
<gf-value
size="medium"
[isCurrency]="false"
[isInteger]="true"
[label]="transactionCount === 1 ? 'Transaction' : 'Transactions'"
[locale]="data.locale"
[value]="transactionCount"

View File

@ -64,8 +64,7 @@ export class PositionComponent implements OnDestroy, OnInit {
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale,
symbol: this.position?.symbol,
title: this.position?.name
symbol: this.position?.symbol
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -87,7 +87,7 @@
}"
(click)="
!this.ignoreAssetClasses.includes(row.assetClass) &&
onOpenPositionDialog({ symbol: row.symbol, title: row.name })
onOpenPositionDialog({ symbol: row.symbol })
"
></tr>
</table>

View File

@ -57,14 +57,9 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (
params['positionDetailDialog'] &&
params['symbol'] &&
params['title']
) {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol'],
title: params['title']
symbol: params['symbol']
});
}
});
@ -96,15 +91,9 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.dataSource.filter = filterValue.trim().toLowerCase();
}*/
public onOpenPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
public onOpenPositionDialog({ symbol }: { symbol: string }): void {
this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol, title }
queryParams: { positionDetailDialog: true, symbol }
});
}
@ -116,18 +105,11 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
});
}
public openPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
public openPositionDialog({ symbol }: { symbol: string }): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
title,
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale

View File

@ -255,8 +255,7 @@
mat-row
(click)="
onOpenPositionDialog({
symbol: row.symbol,
title: row.SymbolProfile?.name
symbol: row.symbol
})
"
></tr>

View File

@ -86,8 +86,7 @@ export class TransactionsTableComponent
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({
symbol: params['symbol'],
title: params['title']
symbol: params['symbol']
});
}
});
@ -196,15 +195,9 @@ export class TransactionsTableComponent
this.import.emit();
}
public onOpenPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
public onOpenPositionDialog({ symbol }: { symbol: string }): void {
this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol, title }
queryParams: { positionDetailDialog: true, symbol }
});
}
@ -216,18 +209,11 @@ export class TransactionsTableComponent
this.transactionToClone.emit(aTransaction);
}
public openPositionDialog({
symbol,
title
}: {
symbol: string;
title: string;
}): void {
public openPositionDialog({ symbol }: { symbol: string }): void {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
symbol,
title,
baseCurrency: this.baseCurrency,
deviceType: this.deviceType,
locale: this.locale

View File

@ -32,7 +32,12 @@
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new feature, please tweet to
new feature, please join the Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack channel"
>Slack channel</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
@ -65,6 +70,14 @@
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"

View File

@ -20,6 +20,7 @@ import {
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import {
PortfolioPerformance,
PortfolioSummary,
@ -111,7 +112,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchSymbolItem('GF.FEAR_AND_GREED_INDEX')
.fetchSymbolItem(ghostfolioFearAndGreedIndexSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
this.fearAndGreedIndex = marketPrice;

View File

@ -16,6 +16,9 @@
right: 0;
top: 0;
margin-bottom: env(safe-area-inset-bottom);
margin-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-tab-body-wrapper {
height: 100%;

View File

@ -94,6 +94,7 @@
[keys]="['name']"
[locale]="user?.settings?.locale"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>

View File

@ -1,7 +1,7 @@
:host {
.allocations-by-symbol {
gf-portfolio-proportion-chart {
max-width: 67vh;
max-width: 80vh;
}
}

View File

@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { WebauthnPageRoutingModule } from './webauthn-page-routing.module';

View File

@ -12,6 +12,9 @@
right: 0;
top: 0;
margin-bottom: env(safe-area-inset-bottom);
margin-bottom: constant(safe-area-inset-bottom);
::ng-deep {
.mat-tab-body-wrapper {
height: 100%;

View File

@ -6,7 +6,10 @@ import {
PublicKeyCredentialRequestOptionsJSON
} from '@ghostfolio/api/app/auth/interfaces/simplewebauthn';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { startAssertion, startAttestation } from '@simplewebauthn/browser';
import {
startAuthentication,
startRegistration
} from '@simplewebauthn/browser';
import { of } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators';
@ -32,7 +35,7 @@ export class WebAuthnService {
public register() {
return this.http
.get<PublicKeyCredentialCreationOptionsJSON>(
`/api/auth/webauthn/generate-attestation-options`,
`/api/auth/webauthn/generate-registration-options`,
{}
)
.pipe(
@ -41,7 +44,7 @@ export class WebAuthnService {
return of(null);
}),
switchMap((attOps) => {
return startAttestation(attOps);
return startRegistration(attOps);
}),
switchMap((attResp) => {
return this.http.post<AuthDeviceDto>(
@ -83,7 +86,7 @@ export class WebAuthnService {
{ deviceId }
)
.pipe(
switchMap(startAssertion),
switchMap(startAuthentication),
switchMap((assertionResponse) => {
return this.http.post<{ authToken: string }>(
`/api/auth/webauthn/verify-assertion`,

View File

@ -27,7 +27,10 @@
name="twitter:title"
content="Ghostfolio Open Source Wealth Management Software"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="initial-scale=1, viewport-fit=cover, width=device-width"
/>
<meta property="og:description" content="" />
<meta
property="og:title"

View File

@ -28,6 +28,7 @@ export const currencyPairs: Partial<
export const ghostfolioScraperApiSymbolPrefix = '_GF_';
export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
export const locale = 'de-CH';

View File

@ -1,6 +1,7 @@
import { PortfolioPerformance } from './portfolio-performance.interface';
export interface PortfolioSummary extends PortfolioPerformance {
annualizedPerformancePercent: number;
cash: number;
committedFunds: number;
fees: number;

View File

@ -1,10 +1,10 @@
import { AccessWithGranteeUser } from './access-with-grantee-user.type';
import { DateRange } from './date-range.type';
import { Granularity } from './granularity.type';
import { OrderWithAccount } from './order-with-account.type';
import { RequestWithUser } from './request-with-user.type';
import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import type { DateRange } from './date-range.type';
import type { Granularity } from './granularity.type';
import type { OrderWithAccount } from './order-with-account.type';
import type { RequestWithUser } from './request-with-user.type';
export {
export type {
AccessWithGranteeUser,
DateRange,
Granularity,

View File

@ -13,7 +13,7 @@
"error",
{
"type": "attribute",
"prefix": "ghostfolio",
"prefix": "gf",
"style": "camelCase"
}
],
@ -21,7 +21,7 @@
"error",
{
"type": "element",
"prefix": "ghostfolio",
"prefix": "gf",
"style": "kebab-case"
}
]

View File

@ -29,17 +29,16 @@ Currency.args = {
value: 7
};
export const Integer = Template.bind({});
Integer.args = {
isInteger: true,
locale: 'en-US',
value: 7
};
export const Label = Template.bind({});
Label.args = {
isInteger: true,
label: 'Label',
locale: 'en-US',
value: 7
value: 7.25
};
export const Precision = Template.bind({});
Precision.args = {
locale: 'en-US',
precision: 3,
value: 7.2534802394809285309
};

View File

@ -18,11 +18,11 @@ export class ValueComponent implements OnChanges {
@Input() colorizeSign = false;
@Input() currency = '';
@Input() isCurrency = false;
@Input() isInteger = false;
@Input() isPercent = false;
@Input() label = '';
@Input() locale = '';
@Input() position = '';
@Input() precision: number | undefined;
@Input() size = '';
@Input() value: number | string = '';
@ -82,13 +82,15 @@ export class ValueComponent implements OnChanges {
minimumFractionDigits: 2
});
} catch {}
} else if (this.isInteger) {
} else if (this.precision || this.precision === 0) {
try {
this.formattedValue = this.value?.toLocaleString(this.locale, {
maximumFractionDigits: 0,
minimumFractionDigits: 0
maximumFractionDigits: this.precision,
minimumFractionDigits: this.precision
});
} catch {}
} else {
this.formattedValue = this.value?.toString();
}
} else {
try {

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.46.0",
"version": "1.52.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -68,9 +68,9 @@
"@nestjs/serve-static": "2.1.4",
"@nrwl/angular": "12.8.0",
"@prisma/client": "2.30.2",
"@simplewebauthn/browser": "3.0.0",
"@simplewebauthn/server": "3.0.0",
"@simplewebauthn/typescript-types": "3.0.0",
"@simplewebauthn/browser": "4.1.0",
"@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "4.0.0",
"@stripe/stripe-js": "1.15.0",
"alphavantage": "2.2.0",
"angular-material-css-vars": "2.1.2",
@ -81,6 +81,7 @@
"cache-manager-redis-store": "2.0.0",
"chart.js": "3.5.0",
"chartjs-adapter-date-fns": "2.0.0",
"chartjs-plugin-datalabels": "2.0.0",
"cheerio": "1.0.0-rc.6",
"class-transformer": "0.3.2",
"class-validator": "0.13.1",

View File

@ -2519,7 +2519,7 @@
consola "^2.15.0"
node-fetch "^2.6.1"
"@peculiar/asn1-android@^2.0.26":
"@peculiar/asn1-android@^2.0.38":
version "2.0.38"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.0.38.tgz#193281f5a232e323d6f2c069c7a8e8e8f4a994bd"
integrity sha512-krWyggV6FgYf3fEPKVNjHVecLcQWlAu3/YhOyN+/L43dNKcsmqiEvuhqplh3aiXF62Ds0pqzqttWmdvoVqmSVQ==
@ -2528,7 +2528,7 @@
asn1js "^2.1.1"
tslib "^2.3.0"
"@peculiar/asn1-schema@^2.0.26", "@peculiar/asn1-schema@^2.0.38":
"@peculiar/asn1-schema@^2.0.38":
version "2.0.38"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.0.38.tgz#98b6f12daad275ecd6774dfe31fb62f362900412"
integrity sha512-zZ64UpCTm9me15nuCpPgJghSdbEm8atcDQPCyK+bKXjZAQ1735NCZXCSCfbckbQ4MH36Rm9403n/qMq77LFDzQ==
@ -2538,7 +2538,7 @@
pvtsutils "^1.2.0"
tslib "^2.3.0"
"@peculiar/asn1-x509@^2.0.26":
"@peculiar/asn1-x509@^2.0.38":
version "2.0.38"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.0.38.tgz#7ff3b5478d9c3784f0eb2fbe7693509da9de0a43"
integrity sha512-10aK9fSxlc1DK9nEcwh+WPFNhAheXSE9RbI5MyS7FdBhgq+Mz4Z9JqFfaBZm1Qp+5mPtUMOP6cXVo7aaYlgq7A==
@ -2613,32 +2613,32 @@
"@angular-devkit/schematics" "12.1.4"
jsonc-parser "3.0.0"
"@simplewebauthn/browser@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-3.0.0.tgz#3d76b199c9f474408a7ed75d86004423dd6ae38a"
integrity sha512-P661gZX/QW0Rg2NRAMtW84Q3u4nhXkPef9LLU4btLJFYoXO8RBFfxcmyqwyf2QEb4B7+lFdp5EWfZV5T7FvuHw==
"@simplewebauthn/browser@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-4.1.0.tgz#3e7fd66729405d6a2a2a187c93577b90a8e41786"
integrity sha512-tIsEfShC1rrqrsNb44tOFuSriAFCz4tkdDnCjHfn2rYxgz+t+yqEvuIRfJHQpFrWSnZPdsjrAHtasj6lzfGI6w==
"@simplewebauthn/server@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-3.0.0.tgz#eb1a5bbe2ecdda54363b178f4bb3e134f25641f0"
integrity sha512-ymGX2obBrhY9R3OxrpCYaNGAovFHmMlQrGoNdVOe2R2JUBXC1Rg5JEUl1lGyaRykN1SyZqLgz86wAjDVuRITTA==
"@simplewebauthn/server@4.1.0":
version "4.1.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-4.1.0.tgz#9ad2e32cffa83833ff8a633775b2ace5e6926fa0"
integrity sha512-52X5/U+5Fo0XYG1TuBBGgG0ap9c0ffpeq0GZfFio/DZDW4He0Arb7Q/XkHw96JK0X1sfRKNmnfC+NImplvIimA==
dependencies:
"@peculiar/asn1-android" "^2.0.26"
"@peculiar/asn1-schema" "^2.0.26"
"@peculiar/asn1-x509" "^2.0.26"
"@simplewebauthn/typescript-types" "^3.0.0"
"@peculiar/asn1-android" "^2.0.38"
"@peculiar/asn1-schema" "^2.0.38"
"@peculiar/asn1-x509" "^2.0.38"
"@simplewebauthn/typescript-types" "^4.0.0"
base64url "^3.0.1"
cbor "^5.1.0"
elliptic "^6.5.3"
jsrsasign "^10.2.0"
jsrsasign "^10.4.0"
jwk-to-pem "^2.0.4"
node-fetch "^2.6.0"
node-rsa "^1.1.1"
"@simplewebauthn/typescript-types@3.0.0", "@simplewebauthn/typescript-types@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-3.0.0.tgz#6712e9619d860f54f571cd27dbe167b2d9e5ab87"
integrity sha512-bsk3EQWzPOZwP9C+ETVhcFDpZywY5sTqmNuGkNm3aNpc9Xh/mqZjy8nL0Sm7xwrlhY0zWAlOaIWQ3LvN5SoFhg==
"@simplewebauthn/typescript-types@4.0.0", "@simplewebauthn/typescript-types@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-4.0.0.tgz#46ae4e69cb07305c57093a3ed99555437dfe0d49"
integrity sha512-jqQ0bCeBO96CytB397vSrQ8ipozQzAmI57izA7izyglyu35JBV90I7+75fSX+ZGNHmMwDNnA3EGYtBLOIpkJEg==
"@sinonjs/commons@^1.7.0":
version "1.8.3"
@ -6112,6 +6112,11 @@ chartjs-adapter-date-fns@2.0.0:
resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b"
integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==
chartjs-plugin-datalabels@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.0.0.tgz#caacefb26803d968785071eab012dde8746c5939"
integrity sha512-WBsWihphzM0Y8fmQVm89+iy99mmgejmj5/jcsYqwxSioLRL/zqJ4Scv/eXq5ZqvG3TpojlGzZLeaOaSvDm7fwA==
check-more-types@^2.24.0:
version "2.24.0"
resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600"
@ -11338,7 +11343,7 @@ jsprim@^1.2.2:
json-schema "0.2.3"
verror "1.10.0"
jsrsasign@^10.2.0:
jsrsasign@^10.4.0:
version "10.4.0"
resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-10.4.0.tgz#362cc787079c03a363a958c03eb68d8545ba92f7"
integrity sha512-C8qLhiAssh/b74KJpGhWuFGG9cFhJqMCVuuHXRibb3Z5vPuAW0ue0jUirpoExCdpdhv4nD3sZ1DAwJURYJTm9g==