Feature/Setup API keys for Ghostfolio data provider (#4093)
* Setup API keys for Ghostfolio data provider
This commit is contained in:
parent
45095cfac0
commit
13582afd93
@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { BenchmarkModule } from './benchmark/benchmark.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 { PublicModule } from './endpoints/public/public.module';
|
||||
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
|
||||
@ -55,6 +56,7 @@ import { UserModule } from './user/user.module';
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
AccountModule,
|
||||
ApiKeysModule,
|
||||
AssetModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
|
76
apps/api/src/app/auth/api-key.strategy.ts
Normal file
76
apps/api/src/app/auth/api-key.strategy.ts
Normal file
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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 { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.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 { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.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 { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { ApiKeyStrategy } from './api-key.strategy';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
@ -28,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
ApiKeyService,
|
||||
ApiKeyStrategy,
|
||||
AuthDeviceService,
|
||||
AuthService,
|
||||
GoogleStrategy,
|
||||
|
25
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
Normal file
25
apps/api/src/app/endpoints/api-keys/api-keys.controller.ts
Normal file
@ -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
Normal file
11
apps/api/src/app/endpoints/api-keys/api-keys.module.ts
Normal file
@ -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 {}
|
@ -18,7 +18,8 @@ import {
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards
|
||||
UseGuards,
|
||||
Version
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -36,9 +37,52 @@ export class GhostfolioController {
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('dividends/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@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(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetDividendsDto
|
||||
@ -75,9 +119,52 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('historical/:symbol')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@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(
|
||||
@Param('symbol') symbol: string,
|
||||
@Query() query: GetHistoricalDto
|
||||
@ -114,9 +201,51 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('lookup')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@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(
|
||||
@Query('includeIndices') includeIndicesParam = 'false',
|
||||
@Query('query') query = ''
|
||||
@ -152,9 +281,48 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('quotes')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@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(
|
||||
@Query() query: GetQuotesDto
|
||||
): Promise<QuotesResponse> {
|
||||
@ -187,9 +355,20 @@ export class GhostfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Get('status')
|
||||
@HasPermission(permissions.enableDataProviderGhostfolio)
|
||||
@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> {
|
||||
return this.ghostfolioService.getStatus({ user: this.request.user });
|
||||
}
|
||||
|
@ -220,8 +220,7 @@ export class GhostfolioService {
|
||||
public async incrementDailyRequests({ userId }: { userId: string }) {
|
||||
await this.prismaService.analytics.update({
|
||||
data: {
|
||||
dataProviderGhostfolioDailyRequests: { increment: 1 },
|
||||
lastRequestAt: new Date()
|
||||
dataProviderGhostfolioDailyRequests: { increment: 1 }
|
||||
},
|
||||
where: { userId }
|
||||
});
|
||||
|
@ -2,6 +2,7 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
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 { 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';
|
||||
@ -37,11 +38,10 @@ import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Prisma, Role, User } from '@prisma/client';
|
||||
import { createHmac } from 'crypto';
|
||||
import { differenceInDays, subDays } from 'date-fns';
|
||||
import { sortBy, without } from 'lodash';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private i18nService = new I18nService();
|
||||
@ -61,7 +61,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
public createAccessToken(password: string, salt: string): string {
|
||||
const hash = crypto.createHmac('sha512', salt);
|
||||
const hash = createHmac('sha512', salt);
|
||||
hash.update(password);
|
||||
|
||||
return hash.digest('hex');
|
||||
@ -309,6 +309,7 @@ export class UserService {
|
||||
// Reset holdings view mode
|
||||
user.Settings.settings.holdingsViewMode = undefined;
|
||||
} else if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.createApiKey);
|
||||
currentPermissions.push(permissions.enableDataProviderGhostfolio);
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
|
||||
@ -408,10 +409,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
if (data.provider === 'ANONYMOUS') {
|
||||
const accessToken = this.createAccessToken(
|
||||
user.id,
|
||||
this.getRandomString(10)
|
||||
);
|
||||
const accessToken = this.createAccessToken(user.id, getRandomString(10));
|
||||
|
||||
const hashedAccessToken = this.createAccessToken(
|
||||
accessToken,
|
||||
@ -528,17 +526,4 @@ export class UserService {
|
||||
|
||||
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
Normal file
14
apps/api/src/helper/string.helper.ts
Normal file
@ -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
Normal file
12
apps/api/src/services/api-key/api-key.module.ts
Normal file
@ -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
Normal file
63
apps/api/src/services/api-key/api-key.service.ts
Normal file
@ -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.findFirst({
|
||||
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('-');
|
||||
}
|
||||
}
|
@ -93,7 +93,7 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
}, requestTimeout);
|
||||
|
||||
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,
|
||||
DATE_FORMAT
|
||||
)}`,
|
||||
@ -111,8 +111,13 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
message = 'RequestError: The daily request limit has been exceeded';
|
||||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
|
||||
if (!error.request?.options?.headers?.authorization?.includes('-')) {
|
||||
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');
|
||||
@ -138,7 +143,7 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
}, requestTimeout);
|
||||
|
||||
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,
|
||||
DATE_FORMAT
|
||||
)}`,
|
||||
@ -158,8 +163,13 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
message = 'RequestError: The daily request limit has been exceeded';
|
||||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
|
||||
if (!error.request?.options?.headers?.authorization?.includes('-')) {
|
||||
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');
|
||||
@ -201,7 +211,7 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
}, requestTimeout);
|
||||
|
||||
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(),
|
||||
// @ts-ignore
|
||||
@ -213,15 +223,20 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
} catch (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 ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
message = 'RequestError: The daily request limit has been exceeded';
|
||||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
|
||||
if (!error.request?.options?.headers?.authorization?.includes('-')) {
|
||||
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');
|
||||
@ -245,7 +260,7 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
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(),
|
||||
// @ts-ignore
|
||||
@ -255,15 +270,20 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
} catch (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 ${(
|
||||
this.configurationService.get('REQUEST_TIMEOUT') / 1000
|
||||
).toFixed(3)} seconds`;
|
||||
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
message = 'RequestError: The daily request limit has been exceeded';
|
||||
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
|
||||
if (!error.request?.options?.headers?.authorization?.includes('-')) {
|
||||
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');
|
||||
@ -278,7 +298,7 @@ export class GhostfolioService implements DataProviderInterface {
|
||||
)) as string;
|
||||
|
||||
return {
|
||||
[HEADER_KEY_TOKEN]: `Bearer ${apiKey}`
|
||||
[HEADER_KEY_TOKEN]: `Api-Key ${apiKey}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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 { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -16,7 +17,7 @@ import {
|
||||
MatSnackBarRef,
|
||||
TextOnlySnackBar
|
||||
} from '@angular/material/snack-bar';
|
||||
import { StringValue } from 'ms';
|
||||
import ms, { StringValue } from 'ms';
|
||||
import { StripeService } from 'ngx-stripe';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
@ -34,6 +35,7 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
public defaultDateFormat: string;
|
||||
public durationExtension: StringValue;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToCreateApiKey: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public price: number;
|
||||
public priceId: string;
|
||||
@ -73,6 +75,11 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
this.user.settings.locale
|
||||
);
|
||||
|
||||
this.hasPermissionToCreateApiKey = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createApiKey
|
||||
);
|
||||
|
||||
this.hasPermissionToUpdateUserSettings = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.updateUserSettings
|
||||
@ -100,15 +107,15 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
this.dataService
|
||||
.createCheckoutSession({ couponId: this.couponId, priceId: this.priceId })
|
||||
.pipe(
|
||||
switchMap(({ sessionId }: { sessionId: string }) => {
|
||||
return this.stripeService.redirectToCheckout({ sessionId });
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.notificationService.alert({
|
||||
title: error.message
|
||||
});
|
||||
|
||||
throw error;
|
||||
}),
|
||||
switchMap(({ sessionId }: { sessionId: string }) => {
|
||||
return this.stripeService.redirectToCheckout({ sessionId });
|
||||
})
|
||||
)
|
||||
.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() {
|
||||
let couponCode = prompt($localize`Please enter your coupon code:`);
|
||||
couponCode = couponCode?.trim();
|
||||
@ -128,18 +170,18 @@ export class UserAccountMembershipComponent implements OnDestroy {
|
||||
this.dataService
|
||||
.redeemCoupon(couponCode)
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeSubject),
|
||||
catchError(() => {
|
||||
this.snackBar.open(
|
||||
'😞 ' + $localize`Could not redeem coupon code`,
|
||||
undefined,
|
||||
{
|
||||
duration: 3000
|
||||
duration: ms('3 seconds')
|
||||
}
|
||||
);
|
||||
|
||||
return EMPTY;
|
||||
})
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.snackBarRef = this.snackBar.open(
|
||||
|
@ -4,7 +4,9 @@
|
||||
<div class="align-items-center d-flex flex-column">
|
||||
<gf-membership-card
|
||||
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
|
||||
[hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey"
|
||||
[name]="user?.subscription?.type"
|
||||
(generateApiKeyClicked)="onGenerateApiKey()"
|
||||
/>
|
||||
@if (user?.subscription?.type === 'Basic') {
|
||||
<div class="d-flex flex-column mt-5">
|
||||
|
@ -1,3 +1,7 @@
|
||||
import {
|
||||
HEADER_KEY_SKIP_INTERCEPTOR,
|
||||
HEADER_KEY_TOKEN
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderGhostfolioStatusResponse,
|
||||
@ -8,7 +12,7 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
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 { format, startOfYear } from 'date-fns';
|
||||
import { map, Observable, Subject, takeUntil } from 'rxjs';
|
||||
@ -28,11 +32,14 @@ export class GfApiPageComponent implements OnInit {
|
||||
public status$: Observable<DataProviderGhostfolioStatusResponse>;
|
||||
public symbols$: Observable<LookupResponse['items']>;
|
||||
|
||||
private apiKey: string;
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(private http: HttpClient) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`);
|
||||
|
||||
this.dividends$ = this.fetchDividends({ symbol: 'KO' });
|
||||
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' });
|
||||
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] });
|
||||
@ -52,8 +59,11 @@ export class GfApiPageComponent implements OnInit {
|
||||
|
||||
return this.http
|
||||
.get<DividendsResponse>(
|
||||
`/api/v1/data-providers/ghostfolio/dividends/${symbol}`,
|
||||
{ params }
|
||||
`/api/v2/data-providers/ghostfolio/dividends/${symbol}`,
|
||||
{
|
||||
params,
|
||||
headers: this.getHeaders()
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
map(({ dividends }) => {
|
||||
@ -70,8 +80,11 @@ export class GfApiPageComponent implements OnInit {
|
||||
|
||||
return this.http
|
||||
.get<HistoricalResponse>(
|
||||
`/api/v1/data-providers/ghostfolio/historical/${symbol}`,
|
||||
{ params }
|
||||
`/api/v2/data-providers/ghostfolio/historical/${symbol}`,
|
||||
{
|
||||
params,
|
||||
headers: this.getHeaders()
|
||||
}
|
||||
)
|
||||
.pipe(
|
||||
map(({ historicalData }) => {
|
||||
@ -85,8 +98,9 @@ export class GfApiPageComponent implements OnInit {
|
||||
const params = new HttpParams().set('symbols', symbols.join(','));
|
||||
|
||||
return this.http
|
||||
.get<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', {
|
||||
params
|
||||
.get<QuotesResponse>('/api/v2/data-providers/ghostfolio/quotes', {
|
||||
params,
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
.pipe(
|
||||
map(({ quotes }) => {
|
||||
@ -99,7 +113,8 @@ export class GfApiPageComponent implements OnInit {
|
||||
private fetchStatus() {
|
||||
return this.http
|
||||
.get<DataProviderGhostfolioStatusResponse>(
|
||||
'/api/v1/data-providers/ghostfolio/status'
|
||||
'/api/v2/data-providers/ghostfolio/status',
|
||||
{ headers: this.getHeaders() }
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeSubject));
|
||||
}
|
||||
@ -118,8 +133,9 @@ export class GfApiPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<LookupResponse>('/api/v1/data-providers/ghostfolio/lookup', {
|
||||
params
|
||||
.get<LookupResponse>('/api/v2/data-providers/ghostfolio/lookup', {
|
||||
params,
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
.pipe(
|
||||
map(({ items }) => {
|
||||
@ -128,4 +144,11 @@ export class GfApiPageComponent implements OnInit {
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
);
|
||||
}
|
||||
|
||||
private getHeaders() {
|
||||
return new HttpHeaders({
|
||||
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
|
||||
[HEADER_KEY_TOKEN]: `Api-Key ${this.apiKey}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ import {
|
||||
Filter
|
||||
} 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 { SortDirection } from '@angular/material/sort';
|
||||
import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
|
||||
@ -147,14 +147,14 @@ export class AdminService {
|
||||
public fetchGhostfolioDataProviderStatus() {
|
||||
return this.fetchAdminData().pipe(
|
||||
switchMap(({ settings }) => {
|
||||
return this.http.get<DataProviderGhostfolioStatusResponse>(
|
||||
`${environment.production ? 'https://ghostfol.io' : ''}/api/v1/data-providers/ghostfolio/status`,
|
||||
{
|
||||
headers: {
|
||||
const headers = new HttpHeaders({
|
||||
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
|
||||
[HEADER_KEY_TOKEN]: `Bearer ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
|
||||
}
|
||||
}
|
||||
[HEADER_KEY_TOKEN]: `Api-Key ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
|
||||
});
|
||||
|
||||
return this.http.get<DataProviderGhostfolioStatusResponse>(
|
||||
`${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`,
|
||||
{ headers }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
@ -22,6 +22,7 @@ import {
|
||||
AccountBalancesResponse,
|
||||
Accounts,
|
||||
AdminMarketDataDetails,
|
||||
ApiKeyResponse,
|
||||
AssetProfileIdentifier,
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse,
|
||||
@ -289,7 +290,7 @@ export class DataService {
|
||||
public deleteActivities({ 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) {
|
||||
@ -636,36 +637,40 @@ export class DataService {
|
||||
}
|
||||
|
||||
public loginAnonymous(accessToken: string) {
|
||||
return this.http.post<OAuthResponse>(`/api/v1/auth/anonymous`, {
|
||||
return this.http.post<OAuthResponse>('/api/v1/auth/anonymous', {
|
||||
accessToken
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
return this.http.post<OrderModel>(`/api/v1/account`, aAccount);
|
||||
return this.http.post<OrderModel>('/api/v1/account', aAccount);
|
||||
}
|
||||
|
||||
public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) {
|
||||
return this.http.post<AccountBalance>(
|
||||
`/api/v1/account-balance`,
|
||||
'/api/v1/account-balance',
|
||||
aAccountBalance
|
||||
);
|
||||
}
|
||||
|
||||
public postApiKey() {
|
||||
return this.http.post<ApiKeyResponse>('/api/v1/api-keys', {});
|
||||
}
|
||||
|
||||
public postBenchmark(benchmark: AssetProfileIdentifier) {
|
||||
return this.http.post(`/api/v1/benchmark`, benchmark);
|
||||
return this.http.post('/api/v1/benchmark', benchmark);
|
||||
}
|
||||
|
||||
public postOrder(aOrder: CreateOrderDto) {
|
||||
return this.http.post<OrderModel>(`/api/v1/order`, aOrder);
|
||||
return this.http.post<OrderModel>('/api/v1/order', aOrder);
|
||||
}
|
||||
|
||||
public postUser() {
|
||||
return this.http.post<UserItem>(`/api/v1/user`, {});
|
||||
return this.http.post<UserItem>('/api/v1/user', {});
|
||||
}
|
||||
|
||||
public putAccount(aAccount: UpdateAccountDto) {
|
||||
@ -692,7 +697,7 @@ export class DataService {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -39,6 +39,7 @@ import type { PortfolioSummary } from './portfolio-summary.interface';
|
||||
import type { Position } from './position.interface';
|
||||
import type { Product } from './product';
|
||||
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 { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface';
|
||||
import type { DividendsResponse } from './responses/dividends-response.interface';
|
||||
@ -72,6 +73,7 @@ export {
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
AdminUsers,
|
||||
ApiKeyResponse,
|
||||
AssetProfileIdentifier,
|
||||
Benchmark,
|
||||
BenchmarkMarketDataDetails,
|
||||
|
@ -0,0 +1,3 @@
|
||||
export interface ApiKeyResponse {
|
||||
apiKey: string;
|
||||
}
|
@ -9,6 +9,7 @@ export const permissions = {
|
||||
createAccess: 'createAccess',
|
||||
createAccount: 'createAccount',
|
||||
createAccountBalance: 'createAccountBalance',
|
||||
createApiKey: 'createApiKey',
|
||||
createOrder: 'createOrder',
|
||||
createPlatform: 'createPlatform',
|
||||
createTag: 'createTag',
|
||||
|
@ -13,6 +13,25 @@
|
||||
[showLabel]="false"
|
||||
/>
|
||||
</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>
|
||||
<div class="heading text-muted" i18n>Membership</div>
|
||||
|
@ -42,6 +42,12 @@
|
||||
background-color: #1d2124;
|
||||
border-radius: calc(var(--borderRadius) - var(--borderWidth));
|
||||
color: rgba(var(--light-primary-text));
|
||||
line-height: 1.2;
|
||||
|
||||
button {
|
||||
color: rgba(var(--light-primary-text));
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-size: 13px;
|
||||
|
@ -3,15 +3,18 @@ import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { GfLogoComponent } from '../logo';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, GfLogoComponent, RouterModule],
|
||||
imports: [CommonModule, GfLogoComponent, MatButtonModule, RouterModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
selector: 'gf-membership-card',
|
||||
standalone: true,
|
||||
@ -20,7 +23,17 @@ import { GfLogoComponent } from '../logo';
|
||||
})
|
||||
export class GfMembershipCardComponent {
|
||||
@Input() public expiresAt: string;
|
||||
@Input() public hasPermissionToCreateApiKey: boolean;
|
||||
@Input() public name: string;
|
||||
|
||||
@Output() generateApiKeyClicked = new EventEmitter<void>();
|
||||
|
||||
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
|
||||
|
||||
public onGenerateApiKey(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.generateApiKeyClicked.emit();
|
||||
}
|
||||
}
|
||||
|
11
package-lock.json
generated
11
package-lock.json
generated
@ -83,6 +83,7 @@
|
||||
"papaparse": "5.3.1",
|
||||
"passport": "0.7.0",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-headerapikey": "1.2.2",
|
||||
"passport-jwt": "4.0.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rxjs": "7.5.6",
|
||||
@ -28414,6 +28415,16 @@
|
||||
"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": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz",
|
||||
|
@ -129,6 +129,7 @@
|
||||
"papaparse": "5.3.1",
|
||||
"passport": "0.7.0",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-headerapikey": "1.2.2",
|
||||
"passport-jwt": "4.0.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rxjs": "7.5.6",
|
||||
|
Loading…
x
Reference in New Issue
Block a user