Browse Source

Merge branch 'ghostfolio:main' into #3955-Extend-notification-service-by-prompt-functionality

pull/4117/head
Brandon 9 months ago
committed by GitHub
parent
commit
bf1f8e3499
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 16
      CHANGELOG.md
  2. 2
      apps/api/src/app/app.module.ts
  3. 76
      apps/api/src/app/auth/api-key.strategy.ts
  4. 4
      apps/api/src/app/auth/auth.module.ts
  5. 25
      apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
  6. 11
      apps/api/src/app/endpoints/api-keys/api-keys.module.ts
  7. 181
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  8. 3
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  9. 1
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  10. 1
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  11. 4
      apps/api/src/app/portfolio/portfolio.controller.ts
  12. 164
      apps/api/src/app/portfolio/portfolio.service.ts
  13. 25
      apps/api/src/app/user/user.service.ts
  14. 14
      apps/api/src/helper/string.helper.ts
  15. 12
      apps/api/src/services/api-key/api-key.module.ts
  16. 63
      apps/api/src/services/api-key/api-key.service.ts
  17. 50
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  18. 4
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  19. 56
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  20. 2
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  21. 43
      apps/client/src/app/pages/api/api-page.component.ts
  22. 31
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  23. 52
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
  24. 16
      apps/client/src/app/services/admin.service.ts
  25. 27
      apps/client/src/app/services/data.service.ts
  26. 6
      libs/common/src/lib/interfaces/index.ts
  27. 5
      libs/common/src/lib/interfaces/portfolio-report.interface.ts
  28. 3
      libs/common/src/lib/interfaces/responses/api-key-response.interface.ts
  29. 9
      libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts
  30. 2
      libs/common/src/lib/models/portfolio-snapshot.ts
  31. 1
      libs/common/src/lib/permissions.ts
  32. 6
      libs/ui/src/lib/assistant/assistant.html
  33. 19
      libs/ui/src/lib/membership-card/membership-card.component.html
  34. 6
      libs/ui/src/lib/membership-card/membership-card.component.scss
  35. 17
      libs/ui/src/lib/membership-card/membership-card.component.ts
  36. 77
      package-lock.json
  37. 9
      package.json
  38. 5
      prisma/migrations/20241207142023_set_hashed_key_of_api_key_to_unique/migration.sql
  39. 3
      prisma/schema.prisma

16
CHANGELOG.md

@ -5,7 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 2.127.0 - 2024-12-08
### Added
- Extended the _X-ray_ page by a summary
### Fixed
- Fixed an exception in the caching of the portfolio snapshot in the portfolio calculator
## 2.126.1 - 2024-12-07
### Added ### Added
@ -13,7 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the labels of the assistant
- Improved the caching of the portfolio snapshot in the portfolio calculator by expiring cache entries immediately in case of errors
- Extracted the historical market data editor to a reusable component - Extracted the historical market data editor to a reusable component
- Upgraded `prettier` from version `3.3.3` to `3.4.2`
- Upgraded `prisma` from version `6.0.0` to `6.0.1`
## 2.125.0 - 2024-11-30 ## 2.125.0 - 2024-11-30

2
apps/api/src/app/app.module.ts

@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module'; import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
@ -55,6 +56,7 @@ import { UserModule } from './user/user.module';
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule, AccountModule,
ApiKeysModule,
AssetModule, AssetModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,

76
apps/api/src/app/auth/api-key.strategy.ts

@ -0,0 +1,76 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config';
import { hasRole } from '@ghostfolio/common/permissions';
import { HttpException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HeaderAPIKeyStrategy } from 'passport-headerapikey';
@Injectable()
export class ApiKeyStrategy extends PassportStrategy(
HeaderAPIKeyStrategy,
'api-key'
) {
public constructor(
private readonly apiKeyService: ApiKeyService,
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly userService: UserService
) {
super(
{ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' },
true,
async (apiKey: string, done: (error: any, user?: any) => void) => {
try {
const user = await this.validateApiKey(apiKey);
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (hasRole(user, 'INACTIVE')) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: {
activityCount: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId: user.id }
});
}
done(null, user);
} catch (error) {
done(error, null);
}
}
);
}
private async validateApiKey(apiKey: string) {
if (!apiKey) {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
}
try {
const { id } = await this.apiKeyService.getUserByApiKey(apiKey);
return this.userService.user({ id });
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.UNAUTHORIZED),
StatusCodes.UNAUTHORIZED
);
}
}
}

4
apps/api/src/app/auth/auth.module.ts

@ -2,6 +2,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -9,6 +10,7 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { ApiKeyStrategy } from './api-key.strategy';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy'; import { GoogleStrategy } from './google.strategy';
@ -28,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy';
UserModule UserModule
], ],
providers: [ providers: [
ApiKeyService,
ApiKeyStrategy,
AuthDeviceService, AuthDeviceService,
AuthService, AuthService,
GoogleStrategy, GoogleStrategy,

25
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts

@ -0,0 +1,25 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
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';
@Controller('api-keys')
export class ApiKeysController {
public constructor(
private readonly apiKeyService: ApiKeyService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.createApiKey)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createApiKey(): Promise<ApiKeyResponse> {
return this.apiKeyService.create({ userId: this.request.user.id });
}
}

11
apps/api/src/app/endpoints/api-keys/api-keys.module.ts

@ -0,0 +1,11 @@
import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module';
import { Module } from '@nestjs/common';
import { ApiKeysController } from './api-keys.controller';
@Module({
controllers: [ApiKeysController],
imports: [ApiKeyModule]
})
export class ApiKeysModule {}

181
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -18,7 +18,8 @@ import {
Inject, Inject,
Param, Param,
Query, Query,
UseGuards UseGuards,
Version
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -36,9 +37,52 @@ export class GhostfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
/**
* @deprecated
*/
@Get('dividends/:symbol') @Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividendsV1(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const dividends = await this.ghostfolioService.getDividends({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return dividends;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getDividends( public async getDividends(
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query() query: GetDividendsDto @Query() query: GetDividendsDto
@ -75,9 +119,52 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('historical/:symbol') @Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHistoricalV1(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const historicalData = await this.ghostfolioService.getHistorical({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return historicalData;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getHistorical( public async getHistorical(
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query() query: GetHistoricalDto @Query() query: GetHistoricalDto
@ -114,9 +201,51 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('lookup') @Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async lookupSymbolV1(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return result;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false', @Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = '' @Query('query') query = ''
@ -152,9 +281,48 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('quotes') @Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getQuotesV1(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return quotes;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getQuotes( public async getQuotes(
@Query() query: GetQuotesDto @Query() query: GetQuotesDto
): Promise<QuotesResponse> { ): Promise<QuotesResponse> {
@ -187,9 +355,20 @@ export class GhostfolioController {
} }
} }
/**
* @deprecated
*/
@Get('status') @Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatusV1(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
@Version('2')
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> { public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user }); return this.ghostfolioService.getStatus({ user: this.request.user });
} }

3
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -220,8 +220,7 @@ export class GhostfolioService {
public async incrementDailyRequests({ userId }: { userId: string }) { public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({ await this.prismaService.analytics.update({
data: { data: {
dataProviderGhostfolioDailyRequests: { increment: 1 }, dataProviderGhostfolioDailyRequests: { increment: 1 }
lastRequestAt: new Date()
}, },
where: { userId } where: { userId }
}); });

1
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -176,6 +176,7 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) { if (!transactionPoints.length) {
return { return {
currentValueInBaseCurrency: new Big(0), currentValueInBaseCurrency: new Big(0),
errors: [],
hasErrors: false, hasErrors: false,
historicalData: [], historicalData: [],
positions: [], positions: [],

1
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -101,6 +101,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
totalInterestWithCurrencyEffect, totalInterestWithCurrencyEffect,
totalInvestment, totalInvestment,
totalInvestmentWithCurrencyEffect, totalInvestmentWithCurrencyEffect,
errors: [],
historicalData: [], historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0), totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0) totalValuablesWithCurrencyEffect: new Big(0)

4
apps/api/src/app/portfolio/portfolio.controller.ts

@ -23,7 +23,7 @@ import {
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReport PortfolioReportResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
hasReadRestrictedAccessPermission, hasReadRestrictedAccessPermission,
@ -611,7 +611,7 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport( public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> { ): Promise<PortfolioReportResponse> {
const report = await this.portfolioService.getReport(impersonationId); const report = await this.portfolioService.getReport(impersonationId);
if ( if (

164
apps/api/src/app/portfolio/portfolio.service.ts

@ -37,7 +37,7 @@ import {
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
PortfolioReport, PortfolioReportResponse,
PortfolioSummary, PortfolioSummary,
Position, Position,
UserSettings UserSettings
@ -1162,7 +1162,9 @@ export class PortfolioService {
}; };
} }
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(
impersonationId: string
): Promise<PortfolioReportResponse> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const userSettings = this.request.user.Settings.settings as UserSettings; const userSettings = this.request.user.Settings.settings as UserSettings;
@ -1179,79 +1181,79 @@ export class PortfolioService {
}) })
).toNumber(); ).toNumber();
return { const rules: PortfolioReportResponse['rules'] = {
rules: { accountClusterRisk:
accountClusterRisk: summary.ordersCount > 0
summary.ordersCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new AccountClusterRiskCurrentInvestment(
new AccountClusterRiskCurrentInvestment( this.exchangeRateDataService,
this.exchangeRateDataService, accounts
accounts ),
), new AccountClusterRiskSingleAccount(
new AccountClusterRiskSingleAccount( this.exchangeRateDataService,
this.exchangeRateDataService, accounts
accounts )
) ],
], userSettings
userSettings
)
: undefined,
economicMarketClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency
)
],
userSettings
)
: undefined,
currencyClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
Object.values(holdings)
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
Object.values(holdings)
)
],
userSettings
)
: undefined,
emergencyFund: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
this.exchangeRateDataService,
userSettings.emergencyFund
) )
], : undefined,
userSettings economicMarketClusterRisk:
), summary.ordersCount > 0
fees: await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new FeeRatioInitialInvestment( new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService, this.exchangeRateDataService,
summary.committedFunds, marketsTotalInBaseCurrency,
summary.fees markets.developedMarkets.valueInBaseCurrency
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency
)
],
userSettings
) )
], : undefined,
userSettings currencyClusterRisk:
) summary.ordersCount > 0
} ? await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
Object.values(holdings)
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
Object.values(holdings)
)
],
userSettings
)
: undefined,
emergencyFund: await this.rulesService.evaluate(
[
new EmergencyFundSetup(
this.exchangeRateDataService,
userSettings.emergencyFund
)
],
userSettings
),
fees: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
summary.committedFunds,
summary.fees
)
],
userSettings
)
}; };
return { rules, statistics: this.getReportStatistics(rules) };
} }
public async updateTags({ public async updateTags({
@ -1670,6 +1672,24 @@ export class PortfolioService {
return { markets, marketsAdvanced }; return { markets, marketsAdvanced };
} }
private getReportStatistics(
evaluatedRules: PortfolioReportResponse['rules']
): PortfolioReportResponse['statistics'] {
const rulesActiveCount = Object.values(evaluatedRules)
.flat()
.filter(({ isActive }) => {
return isActive === true;
}).length;
const rulesFulfilledCount = Object.values(evaluatedRules)
.flat()
.filter(({ value }) => {
return value === true;
}).length;
return { rulesActiveCount, rulesFulfilledCount };
}
private getStreaks({ private getStreaks({
investments, investments,
savingsRate savingsRate

25
apps/api/src/app/user/user.service.ts

@ -2,6 +2,7 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { getRandomString } from '@ghostfolio/api/helper/string.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
@ -37,11 +38,10 @@ import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { createHmac } from 'crypto';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { sortBy, without } from 'lodash'; import { sortBy, without } from 'lodash';
const crypto = require('crypto');
@Injectable() @Injectable()
export class UserService { export class UserService {
private i18nService = new I18nService(); private i18nService = new I18nService();
@ -61,7 +61,7 @@ export class UserService {
} }
public createAccessToken(password: string, salt: string): string { public createAccessToken(password: string, salt: string): string {
const hash = crypto.createHmac('sha512', salt); const hash = createHmac('sha512', salt);
hash.update(password); hash.update(password);
return hash.digest('hex'); return hash.digest('hex');
@ -309,6 +309,7 @@ export class UserService {
// Reset holdings view mode // Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined; user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') { } else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.createApiKey);
currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.reportDataGlitch); currentPermissions.push(permissions.reportDataGlitch);
@ -408,10 +409,7 @@ export class UserService {
} }
if (data.provider === 'ANONYMOUS') { if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken( const accessToken = this.createAccessToken(user.id, getRandomString(10));
user.id,
this.getRandomString(10)
);
const hashedAccessToken = this.createAccessToken( const hashedAccessToken = this.createAccessToken(
accessToken, accessToken,
@ -528,17 +526,4 @@ export class UserService {
return settings; return settings;
} }
private getRandomString(length: number) {
const bytes = crypto.randomBytes(length);
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = [];
for (let i = 0; i < length; i++) {
const randomByte = bytes[i];
result.push(characters[randomByte % characters.length]);
}
return result.join('');
}
} }

14
apps/api/src/helper/string.helper.ts

@ -0,0 +1,14 @@
import { randomBytes } from 'crypto';
export function getRandomString(length: number) {
const bytes = randomBytes(length);
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = [];
for (let i = 0; i < length; i++) {
const randomByte = bytes[i];
result.push(characters[randomByte % characters.length]);
}
return result.join('');
}

12
apps/api/src/services/api-key/api-key.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { ApiKeyService } from './api-key.service';
@Module({
exports: [ApiKeyService],
imports: [PrismaModule],
providers: [ApiKeyService]
})
export class ApiKeyModule {}

63
apps/api/src/services/api-key/api-key.service.ts

@ -0,0 +1,63 @@
import { getRandomString } from '@ghostfolio/api/helper/string.helper';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { ApiKeyResponse } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { pbkdf2Sync } from 'crypto';
@Injectable()
export class ApiKeyService {
private readonly algorithm = 'sha256';
private readonly iterations = 100000;
private readonly keyLength = 64;
public constructor(private readonly prismaService: PrismaService) {}
public async create({ userId }: { userId: string }): Promise<ApiKeyResponse> {
const apiKey = this.generateApiKey();
const hashedKey = this.hashApiKey(apiKey);
await this.prismaService.apiKey.deleteMany({ where: { userId } });
await this.prismaService.apiKey.create({
data: {
hashedKey,
userId
}
});
return { apiKey };
}
public async getUserByApiKey(apiKey: string) {
const hashedKey = this.hashApiKey(apiKey);
const { user } = await this.prismaService.apiKey.findUnique({
include: { user: true },
where: { hashedKey }
});
return user;
}
public hashApiKey(apiKey: string): string {
return pbkdf2Sync(
apiKey,
'',
this.iterations,
this.keyLength,
this.algorithm
).toString('hex');
}
private generateApiKey(): string {
return getRandomString(32)
.split('')
.reduce((acc, char, index) => {
const chunkIndex = Math.floor(index / 4);
acc[chunkIndex] = (acc[chunkIndex] || '') + char;
return acc;
}, [])
.join('-');
}
}

50
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -93,7 +93,7 @@ export class GhostfolioService implements DataProviderInterface {
}, requestTimeout); }, requestTimeout);
const { dividends } = await got( const { dividends } = await got(
`${this.URL}/v1/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to, to,
DATE_FORMAT DATE_FORMAT
)}`, )}`,
@ -111,8 +111,13 @@ export class GhostfolioService implements DataProviderInterface {
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message = if (!error.request?.options?.headers?.authorization?.includes('-')) {
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
@ -138,7 +143,7 @@ export class GhostfolioService implements DataProviderInterface {
}, requestTimeout); }, requestTimeout);
const { historicalData } = await got( const { historicalData } = await got(
`${this.URL}/v1/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to, to,
DATE_FORMAT DATE_FORMAT
)}`, )}`,
@ -158,8 +163,13 @@ export class GhostfolioService implements DataProviderInterface {
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message = if (!error.request?.options?.headers?.authorization?.includes('-')) {
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
@ -201,7 +211,7 @@ export class GhostfolioService implements DataProviderInterface {
}, requestTimeout); }, requestTimeout);
const { quotes } = await got( const { quotes } = await got(
`${this.URL}/v1/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`, `${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
// @ts-ignore // @ts-ignore
@ -213,15 +223,20 @@ export class GhostfolioService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { } else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message = if (!error.request?.options?.headers?.authorization?.includes('-')) {
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
@ -245,7 +260,7 @@ export class GhostfolioService implements DataProviderInterface {
}, this.configurationService.get('REQUEST_TIMEOUT')); }, this.configurationService.get('REQUEST_TIMEOUT'));
searchResult = await got( searchResult = await got(
`${this.URL}/v1/data-providers/ghostfolio/lookup?query=${query}`, `${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
// @ts-ignore // @ts-ignore
@ -255,15 +270,20 @@ export class GhostfolioService implements DataProviderInterface {
} catch (error) { } catch (error) {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { } else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { } else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message = if (!error.request?.options?.headers?.authorization?.includes('-')) {
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
} }
Logger.error(message, 'GhostfolioService'); Logger.error(message, 'GhostfolioService');
@ -278,7 +298,7 @@ export class GhostfolioService implements DataProviderInterface {
)) as string; )) as string;
return { return {
[HEADER_KEY_TOKEN]: `Bearer ${apiKey}` [HEADER_KEY_TOKEN]: `Api-Key ${apiKey}`
}; };
} }
} }

4
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -86,7 +86,9 @@ export class PortfolioSnapshotProcessor {
const expiration = addMilliseconds( const expiration = addMilliseconds(
new Date(), new Date(),
this.configurationService.get('CACHE_QUOTES_TTL') snapshot.errors.length === 0
? this.configurationService.get('CACHE_QUOTES_TTL')
: 0
); );
this.redisCacheService.set( this.redisCacheService.set(

56
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -1,3 +1,4 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -16,7 +17,7 @@ import {
MatSnackBarRef, MatSnackBarRef,
TextOnlySnackBar TextOnlySnackBar
} from '@angular/material/snack-bar'; } from '@angular/material/snack-bar';
import { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators'; import { catchError, switchMap, takeUntil } from 'rxjs/operators';
@ -34,6 +35,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
public defaultDateFormat: string; public defaultDateFormat: string;
public durationExtension: StringValue; public durationExtension: StringValue;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToCreateApiKey: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public price: number; public price: number;
public priceId: string; public priceId: string;
@ -73,6 +75,11 @@ export class UserAccountMembershipComponent implements OnDestroy {
this.user.settings.locale this.user.settings.locale
); );
this.hasPermissionToCreateApiKey = hasPermission(
this.user.permissions,
permissions.createApiKey
);
this.hasPermissionToUpdateUserSettings = hasPermission( this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions, this.user.permissions,
permissions.updateUserSettings permissions.updateUserSettings
@ -100,15 +107,15 @@ export class UserAccountMembershipComponent implements OnDestroy {
this.dataService this.dataService
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) .createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
.pipe( .pipe(
switchMap(({ sessionId }: { sessionId: string }) => {
return this.stripeService.redirectToCheckout({ sessionId });
}),
catchError((error) => { catchError((error) => {
this.notificationService.alert({ this.notificationService.alert({
title: error.message title: error.message
}); });
throw error; throw error;
}),
switchMap(({ sessionId }: { sessionId: string }) => {
return this.stripeService.redirectToCheckout({ sessionId });
}) })
) )
.subscribe((result) => { .subscribe((result) => {
@ -120,6 +127,41 @@ export class UserAccountMembershipComponent implements OnDestroy {
}); });
} }
public onGenerateApiKey() {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
.postApiKey()
.pipe(
catchError(() => {
this.snackBar.open(
'😞 ' + $localize`Could not generate an API key`,
undefined,
{
duration: ms('3 seconds')
}
);
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ apiKey }) => {
this.notificationService.alert({
discardLabel: $localize`Okay`,
message:
$localize`Set this API key in your self-hosted environment:` +
'<br />' +
apiKey,
title: $localize`Ghostfolio Premium Data Provider API Key`
});
});
},
confirmType: ConfirmationDialogType.Primary,
title: $localize`Do you really want to generate a new API key?`
});
}
public onRedeemCoupon() { public onRedeemCoupon() {
let couponCode = prompt($localize`Please enter your coupon code:`); let couponCode = prompt($localize`Please enter your coupon code:`);
couponCode = couponCode?.trim(); couponCode = couponCode?.trim();
@ -128,18 +170,18 @@ export class UserAccountMembershipComponent implements OnDestroy {
this.dataService this.dataService
.redeemCoupon(couponCode) .redeemCoupon(couponCode)
.pipe( .pipe(
takeUntil(this.unsubscribeSubject),
catchError(() => { catchError(() => {
this.snackBar.open( this.snackBar.open(
'😞 ' + $localize`Could not redeem coupon code`, '😞 ' + $localize`Could not redeem coupon code`,
undefined, undefined,
{ {
duration: 3000 duration: ms('3 seconds')
} }
); );
return EMPTY; return EMPTY;
}) }),
takeUntil(this.unsubscribeSubject)
) )
.subscribe(() => { .subscribe(() => {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(

2
apps/client/src/app/components/user-account-membership/user-account-membership.html

@ -4,7 +4,9 @@
<div class="align-items-center d-flex flex-column"> <div class="align-items-center d-flex flex-column">
<gf-membership-card <gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat" [expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey"
[name]="user?.subscription?.type" [name]="user?.subscription?.type"
(generateApiKeyClicked)="onGenerateApiKey()"
/> />
@if (user?.subscription?.type === 'Basic') { @if (user?.subscription?.type === 'Basic') {
<div class="d-flex flex-column mt-5"> <div class="d-flex flex-column mt-5">

43
apps/client/src/app/pages/api/api-page.component.ts

@ -1,3 +1,7 @@
import {
HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
@ -8,7 +12,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { format, startOfYear } from 'date-fns'; import { format, startOfYear } from 'date-fns';
import { map, Observable, Subject, takeUntil } from 'rxjs'; import { map, Observable, Subject, takeUntil } from 'rxjs';
@ -28,11 +32,14 @@ export class GfApiPageComponent implements OnInit {
public status$: Observable<DataProviderGhostfolioStatusResponse>; public status$: Observable<DataProviderGhostfolioStatusResponse>;
public symbols$: Observable<LookupResponse['items']>; public symbols$: Observable<LookupResponse['items']>;
private apiKey: string;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor(private http: HttpClient) {} public constructor(private http: HttpClient) {}
public ngOnInit() { public ngOnInit() {
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
this.dividends$ = this.fetchDividends({ symbol: 'KO' }); this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' });
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] }); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] });
@ -52,8 +59,11 @@ export class GfApiPageComponent implements OnInit {
return this.http return this.http
.get<DividendsResponse>( .get<DividendsResponse>(
`/api/v1/data-providers/ghostfolio/dividends/${symbol}`, `/api/v2/data-providers/ghostfolio/dividends/${symbol}`,
{ params } {
params,
headers: this.getHeaders()
}
) )
.pipe( .pipe(
map(({ dividends }) => { map(({ dividends }) => {
@ -70,8 +80,11 @@ export class GfApiPageComponent implements OnInit {
return this.http return this.http
.get<HistoricalResponse>( .get<HistoricalResponse>(
`/api/v1/data-providers/ghostfolio/historical/${symbol}`, `/api/v2/data-providers/ghostfolio/historical/${symbol}`,
{ params } {
params,
headers: this.getHeaders()
}
) )
.pipe( .pipe(
map(({ historicalData }) => { map(({ historicalData }) => {
@ -85,8 +98,9 @@ export class GfApiPageComponent implements OnInit {
const params = new HttpParams().set('symbols', symbols.join(',')); const params = new HttpParams().set('symbols', symbols.join(','));
return this.http return this.http
.get<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', { .get<QuotesResponse>('/api/v2/data-providers/ghostfolio/quotes', {
params params,
headers: this.getHeaders()
}) })
.pipe( .pipe(
map(({ quotes }) => { map(({ quotes }) => {
@ -99,7 +113,8 @@ export class GfApiPageComponent implements OnInit {
private fetchStatus() { private fetchStatus() {
return this.http return this.http
.get<DataProviderGhostfolioStatusResponse>( .get<DataProviderGhostfolioStatusResponse>(
'/api/v1/data-providers/ghostfolio/status' '/api/v2/data-providers/ghostfolio/status',
{ headers: this.getHeaders() }
) )
.pipe(takeUntil(this.unsubscribeSubject)); .pipe(takeUntil(this.unsubscribeSubject));
} }
@ -118,8 +133,9 @@ export class GfApiPageComponent implements OnInit {
} }
return this.http return this.http
.get<LookupResponse>('/api/v1/data-providers/ghostfolio/lookup', { .get<LookupResponse>('/api/v2/data-providers/ghostfolio/lookup', {
params params,
headers: this.getHeaders()
}) })
.pipe( .pipe(
map(({ items }) => { map(({ items }) => {
@ -128,4 +144,11 @@ export class GfApiPageComponent implements OnInit {
takeUntil(this.unsubscribeSubject) takeUntil(this.unsubscribeSubject)
); );
} }
private getHeaders() {
return new HttpHeaders({
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Api-Key ${this.apiKey}`
});
}
} }

31
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -2,11 +2,28 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h2 class="d-none d-sm-block h3 mb-3 text-center">X-ray</h2> <h2 class="d-none d-sm-block h3 mb-3 text-center">X-ray</h2>
<p class="mb-4" i18n> <p i18n>
Ghostfolio X-ray uses static analysis to uncover potential issues and Ghostfolio X-ray uses static analysis to uncover potential issues and
risks in your portfolio. Adjust the rules below and set custom risks in your portfolio. Adjust the rules below and set custom
thresholds to align with your personal investment strategy. thresholds to align with your personal investment strategy.
</p> </p>
<p class="mb-4">
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="w-100"
[theme]="{
height: '1rem',
width: '100%'
}"
/>
} @else {
{{ statistics?.rulesFulfilledCount }}
<ng-container i18n>of</ng-container>
{{ statistics?.rulesActiveCount }}
<ng-container i18n>rules are currently fulfilled.</ng-container>
}
</p>
<div class="mb-4"> <div class="mb-4">
<h4 class="align-items-center d-flex m-0"> <h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span> <span i18n>Emergency Fund</span>
@ -20,7 +37,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="emergencyFundRules" [rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -39,7 +56,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="currencyClusterRiskRules" [rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -58,7 +75,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="accountClusterRiskRules" [rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -77,7 +94,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="economicMarketClusterRiskRules" [rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -96,7 +113,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="feeRules" [rules]="feeRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -111,7 +128,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="inactiveRules" [rules]="inactiveRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"

52
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts

@ -3,8 +3,8 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
PortfolioReportRule, PortfolioReportResponse,
PortfolioReport PortfolioReportRule
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { User } from '@ghostfolio/common/interfaces/user.interface'; import { User } from '@ghostfolio/common/interfaces/user.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -26,7 +26,8 @@ export class XRayPageComponent {
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[]; public inactiveRules: PortfolioReportRule[];
public isLoadingPortfolioReport = false; public isLoading = false;
public statistics: PortfolioReportResponse['statistics'];
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -87,56 +88,53 @@ export class XRayPageComponent {
} }
private initializePortfolioReport() { private initializePortfolioReport() {
this.isLoadingPortfolioReport = true; this.isLoading = true;
this.dataService this.dataService
.fetchPortfolioReport() .fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => { .subscribe(({ rules, statistics }) => {
this.inactiveRules = this.mergeInactiveRules(portfolioReport); this.inactiveRules = this.mergeInactiveRules(rules);
this.statistics = statistics;
this.accountClusterRiskRules = this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk']?.filter( rules['accountClusterRisk']?.filter(({ isActive }) => {
({ isActive }) => { return isActive;
return isActive; }) ?? null;
}
) ?? null;
this.currencyClusterRiskRules = this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk']?.filter( rules['currencyClusterRisk']?.filter(({ isActive }) => {
({ isActive }) => { return isActive;
return isActive; }) ?? null;
}
) ?? null;
this.economicMarketClusterRiskRules = this.economicMarketClusterRiskRules =
portfolioReport.rules['economicMarketClusterRisk']?.filter( rules['economicMarketClusterRisk']?.filter(({ isActive }) => {
({ isActive }) => { return isActive;
return isActive; }) ?? null;
}
) ?? null;
this.emergencyFundRules = this.emergencyFundRules =
portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => { rules['emergencyFund']?.filter(({ isActive }) => {
return isActive; return isActive;
}) ?? null; }) ?? null;
this.feeRules = this.feeRules =
portfolioReport.rules['fees']?.filter(({ isActive }) => { rules['fees']?.filter(({ isActive }) => {
return isActive; return isActive;
}) ?? null; }) ?? null;
this.isLoadingPortfolioReport = false; this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] { private mergeInactiveRules(
rules: PortfolioReportResponse['rules']
): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = []; let inactiveRules: PortfolioReportRule[] = [];
for (const category in report.rules) { for (const category in rules) {
const rulesArray = report.rules[category]; const rulesArray = rules[category];
inactiveRules = inactiveRules.concat( inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => { rulesArray.filter(({ isActive }) => {

16
apps/client/src/app/services/admin.service.ts

@ -24,7 +24,7 @@ import {
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform, Tag } from '@prisma/client'; import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
@ -147,14 +147,14 @@ export class AdminService {
public fetchGhostfolioDataProviderStatus() { public fetchGhostfolioDataProviderStatus() {
return this.fetchAdminData().pipe( return this.fetchAdminData().pipe(
switchMap(({ settings }) => { switchMap(({ settings }) => {
const headers = new HttpHeaders({
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Api-Key ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
});
return this.http.get<DataProviderGhostfolioStatusResponse>( return this.http.get<DataProviderGhostfolioStatusResponse>(
`${environment.production ? 'https://ghostfol.io' : ''}/api/v1/data-providers/ghostfolio/status`, `${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`,
{ { headers }
headers: {
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Bearer ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
}
}
); );
}) })
); );

27
apps/client/src/app/services/data.service.ts

@ -22,6 +22,7 @@ import {
AccountBalancesResponse, AccountBalancesResponse,
Accounts, Accounts,
AdminMarketDataDetails, AdminMarketDataDetails,
ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
BenchmarkResponse, BenchmarkResponse,
@ -36,7 +37,7 @@ import {
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReport, PortfolioReportResponse,
PublicPortfolioResponse, PublicPortfolioResponse,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -289,7 +290,7 @@ export class DataService {
public deleteActivities({ filters }) { public deleteActivities({ filters }) {
const params = this.buildFiltersAsQueryParams({ filters }); const params = this.buildFiltersAsQueryParams({ filters });
return this.http.delete<any>(`/api/v1/order`, { params }); return this.http.delete<any>('/api/v1/order', { params });
} }
public deleteActivity(aId: string) { public deleteActivity(aId: string) {
@ -612,7 +613,7 @@ export class DataService {
} }
public fetchPortfolioReport() { public fetchPortfolioReport() {
return this.http.get<PortfolioReport>('/api/v1/portfolio/report'); return this.http.get<PortfolioReportResponse>('/api/v1/portfolio/report');
} }
public fetchPublicPortfolio(aAccessId: string) { public fetchPublicPortfolio(aAccessId: string) {
@ -636,36 +637,40 @@ export class DataService {
} }
public loginAnonymous(accessToken: string) { public loginAnonymous(accessToken: string) {
return this.http.post<OAuthResponse>(`/api/v1/auth/anonymous`, { return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
accessToken accessToken
}); });
} }
public postAccess(aAccess: CreateAccessDto) { public postAccess(aAccess: CreateAccessDto) {
return this.http.post<OrderModel>(`/api/v1/access`, aAccess); return this.http.post<OrderModel>('/api/v1/access', aAccess);
} }
public postAccount(aAccount: CreateAccountDto) { public postAccount(aAccount: CreateAccountDto) {
return this.http.post<OrderModel>(`/api/v1/account`, aAccount); return this.http.post<OrderModel>('/api/v1/account', aAccount);
} }
public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) { public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) {
return this.http.post<AccountBalance>( return this.http.post<AccountBalance>(
`/api/v1/account-balance`, '/api/v1/account-balance',
aAccountBalance aAccountBalance
); );
} }
public postApiKey() {
return this.http.post<ApiKeyResponse>('/api/v1/api-keys', {});
}
public postBenchmark(benchmark: AssetProfileIdentifier) { public postBenchmark(benchmark: AssetProfileIdentifier) {
return this.http.post(`/api/v1/benchmark`, benchmark); return this.http.post('/api/v1/benchmark', benchmark);
} }
public postOrder(aOrder: CreateOrderDto) { public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>(`/api/v1/order`, aOrder); return this.http.post<OrderModel>('/api/v1/order', aOrder);
} }
public postUser() { public postUser() {
return this.http.post<UserItem>(`/api/v1/user`, {}); return this.http.post<UserItem>('/api/v1/user', {});
} }
public putAccount(aAccount: UpdateAccountDto) { public putAccount(aAccount: UpdateAccountDto) {
@ -692,7 +697,7 @@ export class DataService {
} }
public putUserSetting(aData: UpdateUserSettingDto) { public putUserSetting(aData: UpdateUserSettingDto) {
return this.http.put<User>(`/api/v1/user/setting`, aData); return this.http.put<User>('/api/v1/user/setting', aData);
} }
public redeemCoupon(couponCode: string) { public redeemCoupon(couponCode: string) {

6
libs/common/src/lib/interfaces/index.ts

@ -34,11 +34,11 @@ import type { PortfolioOverview } from './portfolio-overview.interface';
import type { PortfolioPerformance } from './portfolio-performance.interface'; import type { PortfolioPerformance } from './portfolio-performance.interface';
import type { PortfolioPosition } from './portfolio-position.interface'; import type { PortfolioPosition } from './portfolio-position.interface';
import type { PortfolioReportRule } from './portfolio-report-rule.interface'; import type { PortfolioReportRule } from './portfolio-report-rule.interface';
import type { PortfolioReport } from './portfolio-report.interface';
import type { PortfolioSummary } from './portfolio-summary.interface'; import type { PortfolioSummary } from './portfolio-summary.interface';
import type { Position } from './position.interface'; import type { Position } from './position.interface';
import type { Product } from './product'; import type { Product } from './product';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface'; import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface'; import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface';
import type { DividendsResponse } from './responses/dividends-response.interface'; import type { DividendsResponse } from './responses/dividends-response.interface';
@ -49,6 +49,7 @@ import type { LookupResponse } from './responses/lookup-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PortfolioReportResponse } from './responses/portfolio-report.interface';
import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface'; import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
import type { QuotesResponse } from './responses/quotes-response.interface'; import type { QuotesResponse } from './responses/quotes-response.interface';
import type { ScraperConfiguration } from './scraper-configuration.interface'; import type { ScraperConfiguration } from './scraper-configuration.interface';
@ -72,6 +73,7 @@ export {
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
AdminUsers, AdminUsers,
ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
@ -106,7 +108,7 @@ export {
PortfolioPerformance, PortfolioPerformance,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
PortfolioReport, PortfolioReportResponse,
PortfolioReportRule, PortfolioReportRule,
PortfolioSummary, PortfolioSummary,
Position, Position,

5
libs/common/src/lib/interfaces/portfolio-report.interface.ts

@ -1,5 +0,0 @@
import { PortfolioReportRule } from './portfolio-report-rule.interface';
export interface PortfolioReport {
rules: { [group: string]: PortfolioReportRule[] };
}

3
libs/common/src/lib/interfaces/responses/api-key-response.interface.ts

@ -0,0 +1,3 @@
export interface ApiKeyResponse {
apiKey: string;
}

9
libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts

@ -0,0 +1,9 @@
import { PortfolioReportRule } from '../portfolio-report-rule.interface';
export interface PortfolioReportResponse {
rules: { [group: string]: PortfolioReportRule[] };
statistics: {
rulesActiveCount: number;
rulesFulfilledCount: number;
};
}

2
libs/common/src/lib/models/portfolio-snapshot.ts

@ -13,7 +13,7 @@ export class PortfolioSnapshot {
@Type(() => Big) @Type(() => Big)
currentValueInBaseCurrency: Big; currentValueInBaseCurrency: Big;
errors?: AssetProfileIdentifier[]; errors: AssetProfileIdentifier[];
hasErrors: boolean; hasErrors: boolean;

1
libs/common/src/lib/permissions.ts

@ -9,6 +9,7 @@ export const permissions = {
createAccess: 'createAccess', createAccess: 'createAccess',
createAccount: 'createAccount', createAccount: 'createAccount',
createAccountBalance: 'createAccountBalance', createAccountBalance: 'createAccountBalance',
createApiKey: 'createApiKey',
createOrder: 'createOrder', createOrder: 'createOrder',
createPlatform: 'createPlatform', createPlatform: 'createPlatform',
createTag: 'createTag', createTag: 'createTag',

6
libs/ui/src/lib/assistant/assistant.html

@ -104,7 +104,7 @@
<div class="p-3"> <div class="p-3">
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Accounts</mat-label> <mat-label i18n>Account</mat-label>
<mat-select formControlName="account"> <mat-select formControlName="account">
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (account of accounts; track account.id) { @for (account of accounts; track account.id) {
@ -152,7 +152,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label> <mat-label i18n>Tag</mat-label>
<mat-select formControlName="tag"> <mat-select formControlName="tag">
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (tag of tags; track tag.id) { @for (tag of tags; track tag.id) {
@ -163,7 +163,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint"> <mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Classes</mat-label> <mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass"> <mat-select formControlName="assetClass">
<mat-option [value]="null" /> <mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass.id) { @for (assetClass of assetClasses; track assetClass.id) {

19
libs/ui/src/lib/membership-card/membership-card.component.html

@ -13,6 +13,25 @@
[showLabel]="false" [showLabel]="false"
/> />
</div> </div>
@if (hasPermissionToCreateApiKey) {
<div class="d-none mt-5">
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
</div>
}
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div> <div>
<div class="heading text-muted" i18n>Membership</div> <div class="heading text-muted" i18n>Membership</div>

6
libs/ui/src/lib/membership-card/membership-card.component.scss

@ -42,6 +42,12 @@
background-color: #1d2124; background-color: #1d2124;
border-radius: calc(var(--borderRadius) - var(--borderWidth)); border-radius: calc(var(--borderRadius) - var(--borderWidth));
color: rgba(var(--light-primary-text)); color: rgba(var(--light-primary-text));
line-height: 1.2;
button {
color: rgba(var(--light-primary-text));
height: 1.5rem;
}
.heading { .heading {
font-size: 13px; font-size: 13px;

17
libs/ui/src/lib/membership-card/membership-card.component.ts

@ -3,15 +3,18 @@ import {
CUSTOM_ELEMENTS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
Input EventEmitter,
Input,
Output
} from '@angular/core'; } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLogoComponent } from '../logo'; import { GfLogoComponent } from '../logo';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, GfLogoComponent, RouterModule], imports: [CommonModule, GfLogoComponent, MatButtonModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-membership-card', selector: 'gf-membership-card',
standalone: true, standalone: true,
@ -20,7 +23,17 @@ import { GfLogoComponent } from '../logo';
}) })
export class GfMembershipCardComponent { export class GfMembershipCardComponent {
@Input() public expiresAt: string; @Input() public expiresAt: string;
@Input() public hasPermissionToCreateApiKey: boolean;
@Input() public name: string; @Input() public name: string;
@Output() generateApiKeyClicked = new EventEmitter<void>();
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
public onGenerateApiKey(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
this.generateApiKeyClicked.emit();
}
} }

77
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.125.0", "version": "2.127.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.125.0", "version": "2.127.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -40,7 +40,7 @@
"@nestjs/platform-express": "10.1.3", "@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2", "@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0", "@nestjs/serve-static": "4.0.0",
"@prisma/client": "6.0.0", "@prisma/client": "6.0.1",
"@simplewebauthn/browser": "9.0.1", "@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3", "@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "4.9.0", "@stripe/stripe-js": "4.9.0",
@ -83,6 +83,7 @@
"papaparse": "5.3.1", "papaparse": "5.3.1",
"passport": "0.7.0", "passport": "0.7.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-headerapikey": "1.2.2",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
@ -148,9 +149,9 @@
"jest-environment-jsdom": "29.7.0", "jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.1.0", "jest-preset-angular": "14.1.0",
"nx": "20.1.2", "nx": "20.1.2",
"prettier": "3.3.3", "prettier": "3.4.2",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.0.0", "prisma": "6.0.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "7.0.1", "replace-in-file": "7.0.1",
@ -8352,9 +8353,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@prisma/client": { "node_modules/@prisma/client": {
"version": "6.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.0.1.tgz",
"integrity": "sha512-tOBhG35ozqZ/5Y6B0TNOa6cwULUW8ijXqBXcgb12bfozqf6eGMyGs+jphywCsj6uojv5lAZZnxVSoLMVebIP+g==", "integrity": "sha512-60w7kL6bUxz7M6Gs/V+OWMhwy94FshpngVmOY05TmGD0Lhk+Ac0ZgtjlL6Wll9TD4G03t4Sq1wZekNVy+Xdlbg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
@ -8370,24 +8371,24 @@
} }
}, },
"node_modules/@prisma/debug": { "node_modules/@prisma/debug": {
"version": "6.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.1.tgz",
"integrity": "sha512-eUjoNThlDXdyJ1iQ2d7U6aTVwm59EwvODb5zFVNJEokNoSiQmiYWNzZIwZyDmZ+j51j42/0iTaHIJ4/aZPKFRg==", "integrity": "sha512-jQylgSOf7ibTVxqBacnAlVGvek6fQxJIYCQOeX2KexsfypNzXjJQSS2o5s+Mjj2Np93iSOQUaw6TvPj8syhG4w==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/engines": { "node_modules/@prisma/engines": {
"version": "6.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.0.1.tgz",
"integrity": "sha512-ZZCVP3q22ifN6Ex6C8RIcTDBlRtMJS2H1ljV0knCiWNGArvvkEbE88W3uDdq/l4+UvyvHpGzdf9ZsCWSQR7ZQQ==", "integrity": "sha512-4hxzI+YQIR2uuDyVsDooFZGu5AtixbvM2psp+iayDZ4hRrAHo/YwgA17N23UWq7G6gRu18NvuNMb48qjP3DPQw==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.0.0", "@prisma/debug": "6.0.1",
"@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", "@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e",
"@prisma/fetch-engine": "6.0.0", "@prisma/fetch-engine": "6.0.1",
"@prisma/get-platform": "6.0.0" "@prisma/get-platform": "6.0.1"
} }
}, },
"node_modules/@prisma/engines-version": { "node_modules/@prisma/engines-version": {
@ -8398,25 +8399,25 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@prisma/fetch-engine": { "node_modules/@prisma/fetch-engine": {
"version": "6.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.0.1.tgz",
"integrity": "sha512-j2m+iO5RDPRI7SUc7sHo8wX7SA4iTkJ+18Sxch8KinQM46YiCQD1iXKN6qU79C1Fliw5Bw/qDyTHaTsa3JMerA==", "integrity": "sha512-T36bWFVGeGYYSyYOj9d+O9G3sBC+pAyMC+jc45iSL63/Haq1GrYjQPgPMxrEj9m739taXrupoysRedQ+VyvM/Q==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.0.0", "@prisma/debug": "6.0.1",
"@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", "@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e",
"@prisma/get-platform": "6.0.0" "@prisma/get-platform": "6.0.1"
} }
}, },
"node_modules/@prisma/get-platform": { "node_modules/@prisma/get-platform": {
"version": "6.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.0.0.tgz", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.0.1.tgz",
"integrity": "sha512-PS6nYyIm9g8C03E4y7LknOfdCw/t2KyEJxntMPQHQZCOUgOpF82Ma60mdlOD08w90I3fjLiZZ0+MadenR3naDQ==", "integrity": "sha512-zspC9vlxAqx4E6epMPMLLBMED2VD8axDe8sPnquZ8GOsn6tiacWK0oxrGK4UAHYzYUVuMVUApJbdXB2dFpLhvg==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/debug": "6.0.0" "@prisma/debug": "6.0.1"
} }
}, },
"node_modules/@redis/bloom": { "node_modules/@redis/bloom": {
@ -28414,6 +28415,16 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/passport-headerapikey": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/passport-headerapikey/-/passport-headerapikey-1.2.2.tgz",
"integrity": "sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15",
"passport-strategy": "^1.0.0"
}
},
"node_modules/passport-jwt": { "node_modules/passport-jwt": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
@ -29408,9 +29419,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.3.3", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -29489,14 +29500,14 @@
} }
}, },
"node_modules/prisma": { "node_modules/prisma": {
"version": "6.0.0", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.0.0.tgz", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.0.1.tgz",
"integrity": "sha512-RX7KtbW7IoEByf7MR32JK1PkVYLVYFqeODTtiIX3cqekq1aKdsF3Eud4zp2sUShMLjvdb5Jow0LbUjRq5LVxPw==", "integrity": "sha512-CaMNFHkf+DDq8zq3X/JJsQ4Koy7dyWwwtOKibkT/Am9j/tDxcfbg7+lB1Dzhx18G/+RQCMgjPYB61bhRqteNBQ==",
"devOptional": true, "devOptional": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@prisma/engines": "6.0.0" "@prisma/engines": "6.0.1"
}, },
"bin": { "bin": {
"prisma": "build/index.js" "prisma": "build/index.js"

9
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.125.0", "version": "2.127.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -86,7 +86,7 @@
"@nestjs/platform-express": "10.1.3", "@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2", "@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0", "@nestjs/serve-static": "4.0.0",
"@prisma/client": "6.0.0", "@prisma/client": "6.0.1",
"@simplewebauthn/browser": "9.0.1", "@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3", "@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "4.9.0", "@stripe/stripe-js": "4.9.0",
@ -129,6 +129,7 @@
"papaparse": "5.3.1", "papaparse": "5.3.1",
"passport": "0.7.0", "passport": "0.7.0",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-headerapikey": "1.2.2",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rxjs": "7.5.6", "rxjs": "7.5.6",
@ -194,9 +195,9 @@
"jest-environment-jsdom": "29.7.0", "jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.1.0", "jest-preset-angular": "14.1.0",
"nx": "20.1.2", "nx": "20.1.2",
"prettier": "3.3.3", "prettier": "3.4.2",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.0.0", "prisma": "6.0.1",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"replace-in-file": "7.0.1", "replace-in-file": "7.0.1",

5
prisma/migrations/20241207142023_set_hashed_key_of_api_key_to_unique/migration.sql

@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX "ApiKey_hashedKey_idx";
-- CreateIndex
CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey");

3
prisma/schema.prisma

@ -79,13 +79,12 @@ model Analytics {
model ApiKey { model ApiKey {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
hashedKey String hashedKey String @unique
id String @id @default(uuid()) id String @id @default(uuid())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
userId String userId String
user User @relation(fields: [userId], onDelete: Cascade, references: [id]) user User @relation(fields: [userId], onDelete: Cascade, references: [id])
@@index([hashedKey])
@@index([userId]) @@index([userId])
} }

Loading…
Cancel
Save