Browse Source

Merge remote-tracking branch 'upstream/main' into Support-CSV-Yahoo-Finance-#2288

pull/4056/head
Brandon Wortman 9 months ago
parent
commit
709e6e3092
No known key found for this signature in database GPG Key ID: C63DB7DA05AEC086
  1. 14
      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. 25
      apps/api/src/app/user/user.service.ts
  10. 14
      apps/api/src/helper/string.helper.ts
  11. 12
      apps/api/src/services/api-key/api-key.module.ts
  12. 63
      apps/api/src/services/api-key/api-key.service.ts
  13. 50
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  14. 4
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts
  15. 15
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts
  16. 26
      apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.module.ts
  17. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss
  18. 76
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  19. 48
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  20. 6
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  21. 34
      apps/client/src/app/components/admin-users/admin-users.component.ts
  22. 11
      apps/client/src/app/components/admin-users/admin-users.html
  23. 2
      apps/client/src/app/components/admin-users/admin-users.module.ts
  24. 56
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  25. 2
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  26. 43
      apps/client/src/app/pages/api/api-page.component.ts
  27. 28
      apps/client/src/app/services/admin.service.ts
  28. 23
      apps/client/src/app/services/data.service.ts
  29. 2
      libs/common/src/lib/interfaces/index.ts
  30. 3
      libs/common/src/lib/interfaces/responses/api-key-response.interface.ts
  31. 1
      libs/common/src/lib/permissions.ts
  32. 6
      libs/ui/src/lib/assistant/assistant.html
  33. 42
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts
  34. 0
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html
  35. 0
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.scss
  36. 2
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts
  37. 48
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html
  38. 4
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.scss
  39. 138
      libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts
  40. 1
      libs/ui/src/lib/historical-market-data-editor/index.ts
  41. 19
      libs/ui/src/lib/membership-card/membership-card.component.html
  42. 6
      libs/ui/src/lib/membership-card/membership-card.component.scss
  43. 17
      libs/ui/src/lib/membership-card/membership-card.component.ts
  44. 77
      package-lock.json
  45. 9
      package.json
  46. 5
      prisma/migrations/20241207142023_set_hashed_key_of_api_key_to_unique/migration.sql
  47. 3
      prisma/schema.prisma

14
CHANGELOG.md

@ -5,6 +5,20 @@ 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).
## 2.126.0 - 2024-12-07
### Added
- Added pagination to the users table of the admin control panel
### 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
- 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
### Changed ### Changed

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 }
}); });

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(

15
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts

@ -1,15 +0,0 @@
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
@NgModule({
declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent],
imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataDetailModule {}

26
apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.module.ts

@ -1,26 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
@NgModule({
declarations: [MarketDataDetailDialog],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDatepickerModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfMarketDataDetailDialogModule {}

4
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss

@ -3,5 +3,9 @@
.mat-mdc-dialog-content { .mat-mdc-dialog-content {
max-height: unset; max-height: unset;
gf-line-chart {
aspect-ratio: 16/9;
}
} }
} }

76
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -1,15 +1,17 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.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 { validateObjectForForm } from '@ghostfolio/client/util/form.util'; import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
AssetProfileIdentifier AssetProfileIdentifier,
LineChartItem,
User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
@ -23,7 +25,6 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms'; import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
@ -31,7 +32,6 @@ import {
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators'; import { catchError, takeUntil } from 'rxjs/operators';
@ -75,11 +75,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}; };
public currencies: string[] = []; public currencies: string[] = [];
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
public historicalDataItems: LineChartItem[];
public isBenchmark = false; public isBenchmark = false;
public marketDataDetails: MarketData[] = []; public marketDataItems: MarketData[] = [];
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public user: User;
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(), new Date(),
@ -96,7 +98,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public dialogRef: MatDialogRef<AssetProfileDialog>, public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService, private notificationService: NotificationService,
private snackBar: MatSnackBar private userService: UserService
) {} ) {}
public ngOnInit() { public ngOnInit() {
@ -109,6 +111,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} }
public initialize() { public initialize() {
this.historicalDataItems = undefined;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
this.adminService this.adminService
.fetchAdminMarketDataBySymbol({ .fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
@ -121,10 +133,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.assetProfileClass = translate(this.assetProfile?.assetClass); this.assetProfileClass = translate(this.assetProfile?.assetClass);
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass); this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
this.countries = {}; this.countries = {};
this.isBenchmark = this.benchmarks.some(({ id }) => { this.isBenchmark = this.benchmarks.some(({ id }) => {
return id === this.assetProfile.id; return id === this.assetProfile.id;
}); });
this.marketDataDetails = marketData;
this.historicalDataItems = marketData.map(({ date, marketPrice }) => {
return {
date: format(date, DATE_FORMAT),
value: marketPrice
};
});
this.marketDataItems = marketData;
this.sectors = {}; this.sectors = {};
if (this.assetProfile?.countries?.length > 0) { if (this.assetProfile?.countries?.length > 0) {
@ -200,47 +221,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe(); .subscribe();
} }
public onImportHistoricalData() {
try {
const marketData = csvToJson(
this.assetProfileForm.controls['historicalData'].controls['csvString']
.value,
{
dynamicTyping: true,
header: true,
skipEmptyLines: true
}
).data as UpdateMarketDataDto[];
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData
},
symbol: this.data.symbol
})
.pipe(
catchError(({ error, message }) => {
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
duration: 3000
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.initialize();
});
} catch {
this.snackBar.open(
$localize`Oops! Could not parse historical data.`,
undefined,
{ duration: 3000 }
);
}
}
public onMarketDataChanged(withRefresh: boolean = false) { public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) { if (withRefresh) {
this.initialize(); this.initialize();

48
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -68,50 +68,28 @@
</div> </div>
<div class="flex-grow-1" mat-dialog-content> <div class="flex-grow-1" mat-dialog-content>
<gf-admin-market-data-detail <gf-line-chart
class="mb-4"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="data.locale"
[showXAxis]="true"
[showYAxis]="true"
[symbol]="data.symbol"
/>
<gf-historical-market-data-editor
class="mb-3" class="mb-3"
[currency]="assetProfile?.currency" [currency]="assetProfile?.currency"
[dataSource]="data.dataSource" [dataSource]="data.dataSource"
[dateOfFirstActivity]="assetProfile?.dateOfFirstActivity" [dateOfFirstActivity]="assetProfile?.dateOfFirstActivity"
[locale]="data.locale" [locale]="data.locale"
[marketData]="marketDataDetails" [marketData]="marketDataItems"
[symbol]="data.symbol" [symbol]="data.symbol"
[user]="user"
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"
/> />
<div class="mt-3" formGroupName="historicalData">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
</mat-label>
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
formControlName="csvString"
matInput
type="text"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="d-flex justify-content-end mt-2">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
!assetProfileForm.controls['historicalData']?.controls['csvString']
.touched ||
assetProfileForm.controls['historicalData']?.controls['csvString']
?.value === ''
"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
<div class="row"> <div class="row">
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [value]="assetProfile?.symbol" <gf-value i18n size="medium" [value]="assetProfile?.symbol"

6
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

@ -1,7 +1,8 @@
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
@ -24,9 +25,10 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
imports: [ imports: [
CommonModule, CommonModule,
FormsModule, FormsModule,
GfAdminMarketDataDetailModule,
GfAssetProfileIconComponent, GfAssetProfileIconComponent,
GfCurrencySelectorComponent, GfCurrencySelectorComponent,
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent,
GfPortfolioProportionChartComponent, GfPortfolioProportionChartComponent,
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,

34
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -4,11 +4,19 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper'; import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper';
import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces'; import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import {
ChangeDetectorRef,
Component,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { import {
differenceInSeconds, differenceInSeconds,
@ -24,6 +32,8 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html' templateUrl: './admin-users.html'
}) })
export class AdminUsersComponent implements OnDestroy, OnInit { export class AdminUsersComponent implements OnDestroy, OnInit {
@ViewChild(MatPaginator) paginator: MatPaginator;
public dataSource = new MatTableDataSource<AdminUsers['users'][0]>(); public dataSource = new MatTableDataSource<AdminUsers['users'][0]>();
public defaultDateFormat: string; public defaultDateFormat: string;
public displayedColumns: string[] = []; public displayedColumns: string[] = [];
@ -32,6 +42,8 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
public hasPermissionToImpersonateAllUsers: boolean; public hasPermissionToImpersonateAllUsers: boolean;
public info: InfoItem; public info: InfoItem;
public isLoading = false; public isLoading = false;
public pageSize = DEFAULT_PAGE_SIZE;
public totalItems = 0;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -137,19 +149,33 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
window.location.reload(); window.location.reload();
} }
public onChangePage(page: PageEvent) {
this.fetchUsers({
pageIndex: page.pageIndex
});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchUsers() { private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) {
this.isLoading = true; this.isLoading = true;
if (pageIndex === 0 && this.paginator) {
this.paginator.pageIndex = 0;
}
this.adminService this.adminService
.fetchUsers() .fetchUsers({
skip: pageIndex * this.pageSize,
take: this.pageSize
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => { .subscribe(({ count, users }) => {
this.dataSource = new MatTableDataSource(users); this.dataSource = new MatTableDataSource(users);
this.totalItems = count;
this.isLoading = false; this.isLoading = false;

11
apps/client/src/app/components/admin-users/admin-users.html

@ -267,6 +267,17 @@
></tr> ></tr>
</table> </table>
</div> </div>
<mat-paginator
[length]="totalItems"
[ngClass]="{
'd-none': (isLoading && totalItems === 0) || totalItems <= pageSize
}"
[pageSize]="pageSize"
[showFirstLastButtons]="true"
(page)="onChangePage($event)"
/>
@if (isLoading) { @if (isLoading) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"

2
apps/client/src/app/components/admin-users/admin-users.module.ts

@ -5,6 +5,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -19,6 +20,7 @@ import { AdminUsersComponent } from './admin-users.component';
GfValueComponent, GfValueComponent,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
MatPaginatorModule,
MatTableModule, MatTableModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

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}`
});
}
} }

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

@ -10,6 +10,7 @@ import {
HEADER_KEY_TOKEN, HEADER_KEY_TOKEN,
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -23,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';
@ -146,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]}`
}
}
); );
}) })
); );
@ -179,10 +180,17 @@ export class AdminService {
return this.http.get<Tag[]>('/api/v1/tag'); return this.http.get<Tag[]>('/api/v1/tag');
} }
public fetchUsers() { public fetchUsers({
skip,
take = DEFAULT_PAGE_SIZE
}: {
skip?: number;
take?: number;
}) {
let params = new HttpParams(); let params = new HttpParams();
params = params.append('take', 30); params = params.append('skip', skip);
params = params.append('take', take);
return this.http.get<AdminUsers>('/api/v1/admin/user', { params }); return this.http.get<AdminUsers>('/api/v1/admin/user', { params });
} }

23
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,
@ -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) {
@ -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) {

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

@ -39,6 +39,7 @@ 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';
@ -72,6 +73,7 @@ export {
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem, AdminMarketDataItem,
AdminUsers, AdminUsers,
ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,

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

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

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) {

42
apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts → libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts

@ -1,34 +1,58 @@
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
CUSTOM_ELEMENTS_SCHEMA,
Inject, Inject,
OnDestroy OnDestroy
} from '@angular/core'; } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatDatepickerModule } from '@angular/material/datepicker';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { MarketDataDetailDialogParams } from './interfaces/interfaces'; import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces';
@Component({ @Component({
host: { class: 'h-100' },
selector: 'gf-market-data-detail-dialog',
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
styleUrls: ['./market-data-detail-dialog.scss'], host: { class: 'h-100' },
templateUrl: 'market-data-detail-dialog.html' imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDatepickerModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
],
selector: 'gf-historical-market-data-editor-dialog',
schemas: [CUSTOM_ELEMENTS_SCHEMA],
standalone: true,
styleUrls: ['./historical-market-data-editor-dialog.scss'],
templateUrl: 'historical-market-data-editor-dialog.html'
}) })
export class MarketDataDetailDialog implements OnDestroy { export class GfHistoricalMarketDataEditorDialogComponent implements OnDestroy {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams, @Inject(MAT_DIALOG_DATA)
public data: HistoricalMarketDataEditorDialogParams,
private dateAdapter: DateAdapter<any>, private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<MarketDataDetailDialog>, public dialogRef: MatDialogRef<GfHistoricalMarketDataEditorDialogComponent>,
@Inject(MAT_DATE_LOCALE) private locale: string @Inject(MAT_DATE_LOCALE) private locale: string
) {} ) {}

0
apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html → libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html

0
apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.scss → libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.scss

2
apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts → libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts

@ -2,7 +2,7 @@ import { User } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams { export interface HistoricalMarketDataEditorDialogParams {
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
dateString: string; dateString: string;

48
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html → libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html

@ -1,14 +1,4 @@
<div> <div>
<gf-line-chart
class="mb-4"
[colorScheme]="user?.settings?.colorScheme"
[historicalDataItems]="historicalDataItems"
[isAnimated]="true"
[locale]="locale"
[showXAxis]="true"
[showYAxis]="true"
[symbol]="symbol"
/>
@for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) { @for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) {
<div class="d-flex"> <div class="d-flex">
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div> <div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
@ -43,4 +33,42 @@
</div> </div>
</div> </div>
} }
<form
class="d-flex flex-column h-100"
[formGroup]="historicalDataForm"
(ngSubmit)="onImportHistoricalData()"
>
<div class="mt-3" formGroupName="historicalData">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label>
<ng-container i18n>Historical Data</ng-container> (CSV)
</mat-label>
<textarea
cdkAutosizeMaxRows="5"
cdkTextareaAutosize
formControlName="csvString"
matInput
type="text"
(keyup.enter)="$event.stopPropagation()"
></textarea>
</mat-form-field>
</div>
<div class="d-flex justify-content-end mt-2">
<button
color="accent"
mat-flat-button
type="button"
[disabled]="
!historicalDataForm.controls['historicalData']?.controls['csvString']
.touched ||
historicalDataForm.controls['historicalData']?.controls['csvString']
?.value === ''
"
(click)="onImportHistoricalData()"
>
<ng-container i18n>Import</ng-container>
</button>
</div>
</form>
</div> </div>

4
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss → libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.scss

@ -2,10 +2,6 @@
display: block; display: block;
font-size: 0.9rem; font-size: 0.9rem;
gf-line-chart {
aspect-ratio: 16/9;
}
.date { .date {
font-feature-settings: 'tnum'; font-feature-settings: 'tnum';
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;

138
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts → libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
getDateFormatString, getDateFormatString,
@ -6,15 +7,22 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces'; import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
EventEmitter, EventEmitter,
Input, Input,
OnChanges, OnChanges,
OnDestroy,
OnInit,
Output Output
} from '@angular/core'; } from '@angular/core';
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { import {
addDays, addDays,
@ -29,55 +37,70 @@ import {
parseISO parseISO
} from 'date-fns'; } from 'date-fns';
import { first, last } from 'lodash'; import { first, last } from 'lodash';
import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; import { parse as csvToJson } from 'papaparse';
import { EMPTY, Subject, takeUntil } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { MarketDataDetailDialogParams } from './market-data-detail-dialog/interfaces/interfaces'; import { GfHistoricalMarketDataEditorDialogComponent } from './historical-market-data-editor-dialog/historical-market-data-editor-dialog.component';
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component'; import { HistoricalMarketDataEditorDialogParams } from './historical-market-data-editor-dialog/interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-market-data-detail', imports: [CommonModule, MatButtonModule, MatInputModule, ReactiveFormsModule],
styleUrls: ['./admin-market-data-detail.component.scss'], selector: 'gf-historical-market-data-editor',
templateUrl: './admin-market-data-detail.component.html' standalone: true,
styleUrls: ['./historical-market-data-editor.component.scss'],
templateUrl: './historical-market-data-editor.component.html'
}) })
export class AdminMarketDataDetailComponent implements OnChanges { export class GfHistoricalMarketDataEditorComponent
implements OnChanges, OnDestroy, OnInit
{
@Input() currency: string; @Input() currency: string;
@Input() dataSource: DataSource; @Input() dataSource: DataSource;
@Input() dateOfFirstActivity: string; @Input() dateOfFirstActivity: string;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() marketData: MarketData[]; @Input() marketData: MarketData[];
@Input() symbol: string; @Input() symbol: string;
@Input() user: User;
@Output() marketDataChanged = new EventEmitter<boolean>(); @Output() marketDataChanged = new EventEmitter<boolean>();
public days = Array(31); public days = Array(31);
public defaultDateFormat: string; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public historicalDataForm = this.formBuilder.group({
historicalData: this.formBuilder.group({
csvString: ''
})
});
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public marketDataByMonth: { public marketDataByMonth: {
[yearMonth: string]: { [yearMonth: string]: {
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number }; [day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
}; };
} = {}; } = {};
public user: User;
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
)};123.45`;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private userService: UserService private formBuilder: FormBuilder,
private snackBar: MatSnackBar
) { ) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
this.userService.stateChanged public ngOnInit() {
.pipe(takeUntil(this.unsubscribeSubject)) this.initializeHistoricalDataForm();
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
} }
public ngOnChanges() { public ngOnChanges() {
@ -177,29 +200,84 @@ export class AdminMarketDataDetailComponent implements OnChanges {
}) { }) {
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, { const dialogRef = this.dialog.open(
data: { GfHistoricalMarketDataEditorDialogComponent,
marketPrice, {
currency: this.currency, data: {
dataSource: this.dataSource, marketPrice,
dateString: `${yearMonth}-${day}`, currency: this.currency,
symbol: this.symbol, dataSource: this.dataSource,
user: this.user dateString: `${yearMonth}-${day}`,
} as MarketDataDetailDialogParams, symbol: this.symbol,
height: this.deviceType === 'mobile' ? '98vh' : '80vh', user: this.user
width: this.deviceType === 'mobile' ? '100vw' : '50rem' } as HistoricalMarketDataEditorDialogParams,
}); height: this.deviceType === 'mobile' ? '98vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}
);
dialogRef dialogRef
.afterClosed() .afterClosed()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ withRefresh } = { withRefresh: false }) => { .subscribe(({ withRefresh } = { withRefresh: false }) => {
this.marketDataChanged.next(withRefresh); this.marketDataChanged.emit(withRefresh);
}); });
} }
public onImportHistoricalData() {
try {
const marketData = csvToJson(
this.historicalDataForm.controls['historicalData'].controls['csvString']
.value,
{
dynamicTyping: true,
header: true,
skipEmptyLines: true
}
).data as UpdateMarketDataDto[];
this.adminService
.postMarketData({
dataSource: this.dataSource,
marketData: {
marketData
},
symbol: this.symbol
})
.pipe(
catchError(({ error, message }) => {
this.snackBar.open(`${error}: ${message[0]}`, undefined, {
duration: ms('3 seconds')
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(() => {
this.initializeHistoricalDataForm();
this.marketDataChanged.emit(true);
});
} catch {
this.snackBar.open(
$localize`Oops! Could not parse historical data.`,
undefined,
{ duration: ms('3 seconds') }
);
}
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private initializeHistoricalDataForm() {
this.historicalDataForm.setValue({
historicalData: {
csvString:
GfHistoricalMarketDataEditorComponent.HISTORICAL_DATA_TEMPLATE
}
});
}
} }

1
libs/ui/src/lib/historical-market-data-editor/index.ts

@ -0,0 +1 @@
export * from './historical-market-data-editor.component';

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.126.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.125.0", "version": "2.126.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.126.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