diff --git a/CHANGELOG.md b/CHANGELOG.md index 22406b5b1..eab37019e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support for accesses of type `API` including a token-protected portfolio endpoint (experimental) - Added support for a copy-to-clipboard action in the alert dialog component ### Changed diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index 35b1d485b..12958dea9 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -3,7 +3,11 @@ import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard' import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { SubscriptionType } from '@ghostfolio/common/enums'; -import { Access, AccessSettings } from '@ghostfolio/common/interfaces'; +import { + Access, + AccessSettings, + CreateAccessResponse +} from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; import type { RequestWithUser } from '@ghostfolio/common/types'; @@ -21,8 +25,14 @@ import { } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; -import { Access as AccessModel } from '@prisma/client'; +import { + AccessPermission, + AccessType, + Access as AccessModel +} from '@prisma/client'; +import { endOfDay, isBefore, parseISO } from 'date-fns'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { omit } from 'lodash'; import { AccessService } from './access.service'; @@ -46,15 +56,26 @@ export class AccessController { }); return accessesWithGranteeUser.map( - ({ alias, granteeUser, id, permissions, settings }) => { + ({ alias, expiresAt, granteeUser, id, permissions, settings, type }) => { + if (type === AccessType.API) { + return { + alias, + id, + permissions, + type, + expiresAt: expiresAt ?? undefined, + settings: settings as AccessSettings + }; + } + if (granteeUser) { return { alias, id, permissions, + type, grantee: granteeUser?.id, - settings: settings as AccessSettings, - type: 'PRIVATE' + settings: settings as AccessSettings }; } @@ -62,9 +83,9 @@ export class AccessController { alias, id, permissions, + type, grantee: 'Public', - settings: settings as AccessSettings, - type: 'PUBLIC' + settings: settings as AccessSettings }; } ); @@ -75,7 +96,7 @@ export class AccessController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async createAccess( @Body() data: CreateAccessDto - ): Promise { + ): Promise { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.request.user.subscription.type === SubscriptionType.Basic @@ -86,16 +107,49 @@ export class AccessController { ); } + const type = + data.type ?? + (data.granteeUserId ? AccessType.PRIVATE : AccessType.PUBLIC); + const isApiAccess = type === AccessType.API; + + const expiresAt = data.expiresAt + ? endOfDay(parseISO(data.expiresAt)) + : undefined; + + if ( + (type === AccessType.PRIVATE && !data.granteeUserId) || + (type !== AccessType.PRIVATE && data.granteeUserId) || + (!isApiAccess && expiresAt) || + (expiresAt && isBefore(expiresAt, new Date())) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + try { - return this.accessService.createAccess({ + const { apiToken, hashedApiToken } = isApiAccess + ? this.accessService.createApiToken() + : { apiToken: undefined, hashedApiToken: undefined }; + + const access = await this.accessService.createAccess({ + expiresAt, + hashedApiToken, + type, alias: data.alias || undefined, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, - permissions: data.permissions, + permissions: isApiAccess ? [AccessPermission.READ] : data.permissions, settings: this.accessService.buildSettings(data.filters), user: { connect: { id: this.request.user.id } } }); + + return { + ...omit(access, 'hashedApiToken'), + apiToken + }; } catch { throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), @@ -107,7 +161,9 @@ export class AccessController { @Delete(':id') @HasPermission(permissions.deleteAccess) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) - public async deleteAccess(@Param('id') id: string): Promise { + public async deleteAccess( + @Param('id') id: string + ): Promise> { const originalAccess = await this.accessService.access({ id, userId: this.request.user.id @@ -120,9 +176,11 @@ export class AccessController { ); } - return this.accessService.deleteAccess({ + const access = await this.accessService.deleteAccess({ id }); + + return omit(access, 'hashedApiToken'); } @HasPermission(permissions.updateAccess) @@ -131,7 +189,7 @@ export class AccessController { public async updateAccess( @Body() data: UpdateAccessDto, @Param('id') id: string - ): Promise { + ): Promise> { if ( this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.request.user.subscription.type === SubscriptionType.Basic @@ -154,18 +212,34 @@ export class AccessController { ); } + const isApiAccess = originalAccess.type === AccessType.API; + + if ( + (originalAccess.type === AccessType.PRIVATE && !data.granteeUserId) || + (originalAccess.type !== AccessType.PRIVATE && data.granteeUserId) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + try { - return this.accessService.updateAccess({ + const access = await this.accessService.updateAccess({ data: { alias: data.alias, granteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } - : { disconnect: true }, - permissions: data.permissions, - settings: this.accessService.buildSettings(data.filters) + : undefined, + permissions: isApiAccess ? undefined : data.permissions, + settings: data.filters + ? this.accessService.buildSettings(data.filters) + : undefined }, where: { id } }); + + return omit(access, 'hashedApiToken'); } catch { throw new HttpException( getReasonPhrase(StatusCodes.BAD_REQUEST), diff --git a/apps/api/src/app/access/access.module.ts b/apps/api/src/app/access/access.module.ts index 44d28a230..b41f3c716 100644 --- a/apps/api/src/app/access/access.module.ts +++ b/apps/api/src/app/access/access.module.ts @@ -1,3 +1,4 @@ +import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; @@ -9,7 +10,7 @@ import { AccessService } from './access.service'; @Module({ controllers: [AccessController], exports: [AccessService], - imports: [ConfigurationModule, PrismaModule], + imports: [ApiKeyModule, ConfigurationModule, PrismaModule], providers: [AccessService] }) export class AccessModule {} diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index e50a6c7d0..7055ed420 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -1,3 +1,6 @@ +import { createSha512HmacHash } from '@ghostfolio/api/helper/hash.helper'; +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 { AccessSettings, Filter } from '@ghostfolio/common/interfaces'; import { AccessWithGranteeUser } from '@ghostfolio/common/types'; @@ -7,7 +10,11 @@ import { Access, Prisma } from '@prisma/client'; @Injectable() export class AccessService { - public constructor(private readonly prismaService: PrismaService) {} + public constructor( + private readonly apiKeyService: ApiKeyService, + private readonly configurationService: ConfigurationService, + private readonly prismaService: PrismaService + ) {} public async access( accessWhereInput: Prisma.AccessWhereInput @@ -52,6 +59,15 @@ export class AccessService { }); } + public createApiToken() { + const apiToken = this.apiKeyService.generateApiKey(); + + return { + apiToken, + hashedApiToken: this.hashApiToken(apiToken) + }; + } + public async deleteAccess( where: Prisma.AccessWhereUniqueInput ): Promise { @@ -60,6 +76,17 @@ export class AccessService { }); } + public async getAccessByApiToken( + apiToken: string + ): Promise { + return this.prismaService.access.findUnique({ + include: { + granteeUser: true + }, + where: { hashedApiToken: this.hashApiToken(apiToken) } + }); + } + public async updateAccess({ data, where @@ -72,4 +99,12 @@ export class AccessService { where }); } + + private hashApiToken(apiToken: string) { + // Rotating ACCESS_TOKEN_SALT invalidates all stored api tokens + return createSha512HmacHash( + apiToken, + this.configurationService.get('ACCESS_TOKEN_SALT') + ); + } } diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 04b1f3bf2..338791fbb 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -38,6 +38,7 @@ import { AuthModule } from './auth/auth.module'; import { CacheModule } from './cache/cache.module'; import { AiModule } from './endpoints/ai/ai.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; +import { ApiModule } from './endpoints/api/api.module'; import { AssetProfilesModule } from './endpoints/asset-profiles/asset-profiles.module'; import { AssetsModule } from './endpoints/assets/assets.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; @@ -69,6 +70,7 @@ import { UserModule } from './user/user.module'; AccountModule, ActivitiesModule, AiModule, + ApiModule, ApiKeysModule, AssetProfilesModule, AssetModule, diff --git a/apps/api/src/app/auth/api-access-token.strategy.ts b/apps/api/src/app/auth/api-access-token.strategy.ts new file mode 100644 index 000000000..5d29a01c6 --- /dev/null +++ b/apps/api/src/app/auth/api-access-token.strategy.ts @@ -0,0 +1,85 @@ +import { AccessService } from '@ghostfolio/api/app/access/access.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + DEFAULT_CURRENCY, + DEFAULT_LANGUAGE_CODE, + HEADER_KEY_TOKEN +} from '@ghostfolio/common/config'; +import { hasRole } from '@ghostfolio/common/permissions'; +import { UserWithApiAccess } from '@ghostfolio/common/types'; + +import { HttpException, Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { AccessType } from '@prisma/client'; +import { isBefore } from 'date-fns'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; + +@Injectable() +export class ApiAccessTokenStrategy extends PassportStrategy( + HeaderAPIKeyStrategy, + 'api-access-token' +) { + public constructor( + private readonly accessService: AccessService, + private readonly configurationService: ConfigurationService, + private readonly userService: UserService + ) { + super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false); + } + + public async validate(apiToken: string): Promise { + if (!apiToken) { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + + const access = await this.accessService.getAccessByApiToken(apiToken); + + if ( + !access || + access.type !== AccessType.API || + (access.expiresAt && isBefore(access.expiresAt, new Date())) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + + const user = await this.userService.user({ id: access.userId }); + + if (!user) { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + hasRole(user, 'INACTIVE') + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + if (!user.settings.settings.baseCurrency) { + user.settings.settings.baseCurrency = DEFAULT_CURRENCY; + } + + if (!user.settings.settings.language) { + user.settings.settings.language = DEFAULT_LANGUAGE_CODE; + } + + // The api token only grants a read-only view of the portfolio + user.permissions = []; + + return Object.assign(user, { apiAccess: access }); + } +} diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 1d6990307..2378de015 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -1,3 +1,4 @@ +import { AccessModule } from '@ghostfolio/api/app/access/access.module'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; @@ -14,6 +15,7 @@ import { Logger, Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import type { StrategyOptions } from 'passport-openidconnect'; +import { ApiAccessTokenStrategy } from './api-access-token.strategy'; import { ApiKeyStrategy } from './api-key.strategy'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; @@ -24,6 +26,7 @@ import { OidcStrategy } from './oidc.strategy'; @Module({ controllers: [AuthController], imports: [ + AccessModule, ConfigurationModule, FetchModule, JwtModule.register({ @@ -36,6 +39,7 @@ import { OidcStrategy } from './oidc.strategy'; UserModule ], providers: [ + ApiAccessTokenStrategy, ApiKeyService, ApiKeyStrategy, AuthDeviceService, diff --git a/apps/api/src/app/endpoints/api/api.controller.ts b/apps/api/src/app/endpoints/api/api.controller.ts new file mode 100644 index 000000000..738ece9cf --- /dev/null +++ b/apps/api/src/app/endpoints/api/api.controller.ts @@ -0,0 +1,83 @@ +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { getSum } from '@ghostfolio/common/helper'; +import { + AccessSettings, + ApiPortfolioResponse +} from '@ghostfolio/common/interfaces'; +import type { UserWithApiAccess } from '@ghostfolio/common/types'; + +import { + Controller, + Get, + Inject, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Big } from 'big.js'; + +@Controller('api') +export class ApiController { + public constructor( + private readonly portfolioService: PortfolioService, + @Inject(REQUEST) private readonly request: { user: UserWithApiAccess } + ) {} + + @Get('portfolio') + @UseGuards(AuthGuard('api-access-token')) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getPortfolio(): Promise { + const { apiAccess, ...user } = this.request.user; + + const { filters } = (apiAccess.settings ?? {}) as AccessSettings; + + const { createdAt, holdings, latestActivities, markets, performance } = + await this.portfolioService.getPortfolioOverview({ + filters, + impersonationId: undefined, + userCurrency: user.settings.settings.baseCurrency ?? DEFAULT_CURRENCY, + userId: user.id + }); + + const totalValueInBaseCurrency = getSum( + Object.values(holdings).map(({ valueInBaseCurrency }) => { + return new Big(valueInBaseCurrency ?? 0); + }) + ).toNumber(); + + const apiPortfolioResponse: ApiPortfolioResponse = { + createdAt, + latestActivities, + markets, + performance, + totalValueInBaseCurrency, + alias: apiAccess.alias, + holdings: {} + }; + + for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + const allocationInPercentage = + totalValueInBaseCurrency > 0 + ? (portfolioPosition.valueInBaseCurrency ?? 0) / + totalValueInBaseCurrency + : 0; + + apiPortfolioResponse.holdings[symbol] = { + allocationInPercentage, + assetProfile: portfolioPosition.assetProfile, + dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, + markets: portfolioPosition.markets, + netPerformancePercentWithCurrencyEffect: + portfolioPosition.netPerformancePercentWithCurrencyEffect, + quantity: portfolioPosition.quantity, + valueInBaseCurrency: portfolioPosition.valueInBaseCurrency, + valueInPercentage: allocationInPercentage + }; + } + + return apiPortfolioResponse; + } +} diff --git a/apps/api/src/app/endpoints/api/api.module.ts b/apps/api/src/app/endpoints/api/api.module.ts new file mode 100644 index 000000000..6ea63c82e --- /dev/null +++ b/apps/api/src/app/endpoints/api/api.module.ts @@ -0,0 +1,12 @@ +import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; + +import { Module } from '@nestjs/common'; + +import { ApiController } from './api.controller'; + +@Module({ + controllers: [ApiController], + imports: [PortfolioModule, TransformDataSourceInResponseModule] +}) +export class ApiModule {} diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts index f43e51f7b..cc736c9e4 100644 --- a/apps/api/src/app/endpoints/public/public.controller.ts +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -1,5 +1,4 @@ import { AccessService } from '@ghostfolio/api/app/access/access.service'; -import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; @@ -21,11 +20,7 @@ import { Param, UseInterceptors } from '@nestjs/common'; -import { - AssetClass, - AssetSubClass, - Type as ActivityType -} from '@prisma/client'; +import { AccessType, AssetClass, AssetSubClass } from '@prisma/client'; import { Big } from 'big.js'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -33,7 +28,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; export class PublicController { public constructor( private readonly accessService: AccessService, - private readonly activitiesService: ActivitiesService, private readonly configurationService: ConfigurationService, private readonly exchangeRateDataService: ExchangeRateDataService, private readonly portfolioService: PortfolioService, @@ -48,7 +42,8 @@ export class PublicController { ): Promise { const access = await this.accessService.access({ granteeUserId: null, - id: accessId + id: accessId, + type: AccessType.PUBLIC }); if (!access) { @@ -70,69 +65,13 @@ export class PublicController { const { filters } = (access.settings ?? {}) as AccessSettings; - const [ - { createdAt, holdings, markets }, - { performance: performance1d }, - { performance: performanceMax }, - { performance: performanceYtd } - ] = await Promise.all([ - this.portfolioService.getDetails({ + const { createdAt, holdings, latestActivities, markets, performance } = + await this.portfolioService.getPortfolioOverview({ filters, impersonationId: access.userId, - userId: user.id, - withMarkets: true - }), - ...['1d', 'max', 'ytd'].map((dateRange) => { - return this.portfolioService.getPerformance({ - dateRange, - filters, - impersonationId: undefined, - userId: user.id - }); - }) - ]); - - const { activities } = await this.activitiesService.getActivities({ - filters, - sortColumn: 'date', - sortDirection: 'desc', - take: 10, - types: [ActivityType.BUY, ActivityType.SELL], - userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, - userId: user.id, - withExcludedAccountsAndActivities: false - }); - - // Experimental - const latestActivities = this.configurationService.get( - 'ENABLE_FEATURE_SUBSCRIPTION' - ) - ? [] - : activities.map( - ({ - currency, - date, - fee, - quantity, - SymbolProfile, - type, - unitPrice, - value, - valueInBaseCurrency - }) => { - return { - currency, - date, - fee, - quantity, - SymbolProfile, - type, - unitPrice, - value, - valueInBaseCurrency - }; - } - ); + userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, + userId: user.id + }); Object.values(markets ?? {}).forEach((market) => { delete market.valueInBaseCurrency; @@ -141,24 +80,16 @@ export class PublicController { const publicPortfolioResponse: PublicPortfolioResponse = { createdAt, hasDetails, - latestActivities, markets, + performance, alias: access.alias, holdings: {}, - performance: { - '1d': { - relativeChange: - performance1d.netPerformancePercentageWithCurrencyEffect - }, - max: { - relativeChange: - performanceMax.netPerformancePercentageWithCurrencyEffect - }, - ytd: { - relativeChange: - performanceYtd.netPerformancePercentageWithCurrencyEffect - } - } + // Experimental + latestActivities: this.configurationService.get( + 'ENABLE_FEATURE_SUBSCRIPTION' + ) + ? [] + : latestActivities }; const totalValue = getSum( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index e75e54890..27ecb08a9 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1100,6 +1100,98 @@ export class PortfolioService { }; } + public async getPortfolioOverview({ + filters, + impersonationId, + userCurrency, + userId + }: { + filters?: Filter[]; + impersonationId: string; + userCurrency: string; + userId: string; + }) { + const [ + { activities }, + { createdAt, holdings, markets }, + { performance: performance1d }, + { performance: performanceMax }, + { performance: performanceYtd } + ] = await Promise.all([ + this.activitiesService.getActivities({ + filters, + userCurrency, + userId, + sortColumn: 'date', + sortDirection: 'desc', + take: 10, + types: [ActivityType.BUY, ActivityType.SELL], + withExcludedAccountsAndActivities: false + }), + this.getDetails({ + filters, + impersonationId, + userId, + withMarkets: true + }), + ...(['1d', 'max', 'ytd'] as DateRange[]).map((dateRange) => { + return this.getPerformance({ + dateRange, + filters, + userId, + impersonationId: undefined + }); + }) + ]); + + const latestActivities = activities.map( + ({ + currency, + date, + fee, + quantity, + SymbolProfile, + type, + unitPrice, + value, + valueInBaseCurrency + }) => { + return { + currency, + date, + fee, + quantity, + SymbolProfile, + type, + unitPrice, + value, + valueInBaseCurrency + }; + } + ); + + return { + createdAt, + holdings, + latestActivities, + markets, + performance: { + '1d': { + relativeChange: + performance1d.netPerformancePercentageWithCurrencyEffect + }, + max: { + relativeChange: + performanceMax.netPerformancePercentageWithCurrencyEffect + }, + ytd: { + relativeChange: + performanceYtd.netPerformancePercentageWithCurrencyEffect + } + } + }; + } + public async getReport({ impersonationId, userId diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 4dcf16034..a0d30260f 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -2,6 +2,7 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser 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 { createSha512HmacHash } from '@ghostfolio/api/helper/hash.helper'; 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'; @@ -54,7 +55,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, Role, Settings, User } from '@prisma/client'; import { differenceInDays, subDays } from 'date-fns'; import { without } from 'lodash'; -import { createHmac } from 'node:crypto'; @Injectable() export class UserService { @@ -80,10 +80,7 @@ export class UserService { password: string; salt: string; }): string { - const hash = createHmac('sha512', salt); - hash.update(password); - - return hash.digest('hex'); + return createSha512HmacHash(password, salt); } public generateAccessToken({ userId }: { userId: string }) { diff --git a/apps/api/src/helper/hash.helper.ts b/apps/api/src/helper/hash.helper.ts new file mode 100644 index 000000000..4fdaa0a3c --- /dev/null +++ b/apps/api/src/helper/hash.helper.ts @@ -0,0 +1,8 @@ +import { createHmac } from 'node:crypto'; + +export function createSha512HmacHash(value: string, salt: string) { + const hash = createHmac('sha512', salt); + hash.update(value); + + return hash.digest('hex'); +} diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts index 56d260d4d..d3fc5343e 100644 --- a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -88,6 +88,7 @@ export class TransformDataSourceInResponseInterceptor< 'holdings[*].assetProfile.dataSource', 'holdings[*].dataSource', 'items[*].dataSource', + 'latestActivities[*].SymbolProfile.dataSource', 'SymbolProfile.dataSource', 'watchlist[*].dataSource' ] diff --git a/apps/api/src/services/api-key/api-key.service.ts b/apps/api/src/services/api-key/api-key.service.ts index b911191dc..120348a37 100644 --- a/apps/api/src/services/api-key/api-key.service.ts +++ b/apps/api/src/services/api-key/api-key.service.ts @@ -29,6 +29,17 @@ export class ApiKeyService { return { apiKey }; } + public generateApiKey(): string { + return getRandomString(32) + .split('') + .reduce((acc, char, index) => { + const chunkIndex = Math.floor(index / 4); + acc[chunkIndex] = (acc[chunkIndex] || '') + char; + return acc; + }, []) + .join('-'); + } + public async getUserByApiKey(apiKey: string) { const hashedKey = this.hashApiKey(apiKey); @@ -49,15 +60,4 @@ export class ApiKeyService { 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('-'); - } } diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts index 71c543a43..bebdc9841 100644 --- a/apps/api/src/services/impersonation/impersonation.service.ts +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -4,6 +4,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types'; import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; +import { AccessType } from '@prisma/client'; @Injectable() export class ImpersonationService { @@ -36,6 +37,7 @@ export class ImpersonationService { const accessObject = await this.prismaService.access.findFirst({ where: { granteeUserId: null, + type: AccessType.PUBLIC, user: { id: aId } } }); diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index 9dd6c8374..f2c8b5c51 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -32,6 +32,17 @@ Details + @if (element.type === 'API') { +
+ GET {{ baseUrl }}/api/v1/api/portfolio +
+ @if (element.expiresAt) { +
+ Valid until + {{ element.expiresAt | date: defaultDateFormat() }} +
+ } + } @if (element.type === 'PUBLIC') {
diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts index 122b4f88b..07df13954 100644 --- a/apps/client/src/app/components/access-table/access-table.component.ts +++ b/apps/client/src/app/components/access-table/access-table.component.ts @@ -1,9 +1,11 @@ import { ConfirmationDialogType } from '@ghostfolio/common/enums'; +import { getDateFormatString } from '@ghostfolio/common/helper'; import { Access, User } from '@ghostfolio/common/interfaces'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; +import { DatePipe } from '@angular/common'; import { ChangeDetectionStrategy, Component, @@ -36,6 +38,7 @@ import ms from 'ms'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, + DatePipe, IonIcon, MatButtonModule, MatMenuModule, @@ -58,6 +61,10 @@ export class GfAccessTableComponent { protected readonly baseUrl = window.location.origin; protected readonly dataSource = new MatTableDataSource(); + protected readonly defaultDateFormat = computed(() => { + return getDateFormatString(this.user()?.settings?.locale); + }); + protected readonly displayedColumns = computed(() => { const columns = ['alias', 'grantee', 'type', 'details']; diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index ef6d0ab39..faabdf0ab 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -32,6 +32,7 @@ import { Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -40,7 +41,7 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; -import { AccessPermission } from '@prisma/client'; +import { AccessPermission, AccessType } from '@prisma/client'; import { StatusCodes } from 'http-status-codes'; import { EMPTY, catchError } from 'rxjs'; @@ -53,6 +54,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; FormsModule, GfPortfolioFilterFormComponent, MatButtonModule, + MatDatepickerModule, MatDialogModule, MatFormFieldModule, MatInputModule, @@ -70,9 +72,11 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { public tags: Filter[] = []; protected accessForm: FormGroup; + protected hasExperimentalFeatures = false; + protected readonly minExpiresAtDate = new Date(); protected readonly mode: 'create' | 'update'; - private hasExperimentalFeatures = false; + private hasLoadedFilters = false; private readonly changeDetectorRef = inject(ChangeDetectorRef); @@ -95,21 +99,27 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { public get canApplyFilters() { return ( - this.accessForm?.get('type')?.value === 'PUBLIC' && + ['API', 'PUBLIC'].includes(this.accessForm?.get('type')?.value) && this.hasExperimentalFeatures ); } public ngOnInit() { const access = this.data?.access; - const isPublic = access?.type === 'PUBLIC'; + const isPrivate = (access?.type ?? 'PRIVATE') === 'PRIVATE'; this.accessForm = this.formBuilder.group({ alias: [access?.alias ?? ''], + expiresAt: [ + { + disabled: this.mode === 'update', + value: access?.expiresAt ?? null + } + ], filters: [null], granteeUserId: [ access?.grantee ?? null, - isPublic ? null : Validators.required + isPrivate ? Validators.required : null ], permissions: [ access?.permissions[0] ?? AccessPermission.READ_RESTRICTED, @@ -138,6 +148,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { .get('type') ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((accessType) => { + const expiresAtControl = this.accessForm.get('expiresAt'); const granteeUserIdControl = this.accessForm.get('granteeUserId'); const permissionsControl = this.accessForm.get('permissions'); @@ -147,9 +158,18 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } else { granteeUserIdControl?.clearValidators(); granteeUserIdControl?.setValue(null); - permissionsControl?.setValue( - access?.permissions[0] ?? AccessPermission.READ_RESTRICTED - ); + + if (accessType === 'API') { + permissionsControl?.setValue(AccessPermission.READ); + } else { + permissionsControl?.setValue( + access?.permissions[0] ?? AccessPermission.READ_RESTRICTED + ); + } + } + + if (accessType !== 'API') { + expiresAtControl?.setValue(null); } granteeUserIdControl?.updateValueAndValidity(); @@ -181,8 +201,14 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { private async createAccess() { const filters = this.buildFilters(); + const expiresAt: Date | null = this.accessForm.get('expiresAt')?.value; + const type: AccessType = this.accessForm.get('type')?.value; + const access: CreateAccessDto = { + type, alias: this.accessForm.get('alias')?.value, + expiresAt: + type === 'API' && expiresAt ? expiresAt.toISOString() : undefined, filters: filters.length > 0 ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, permissions: [this.accessForm.get('permissions')?.value] @@ -209,8 +235,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { }), takeUntilDestroyed(this.destroyRef) ) - .subscribe(() => { - this.dialogRef.close(access); + .subscribe((response) => { + this.dialogRef.close(response); }); } catch (error) { console.error(error); @@ -226,6 +252,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.updateFiltersFormControl(this.data.access?.settings?.filters); + this.hasLoadedFilters = true; + this.changeDetectorRef.markForCheck(); }); } @@ -241,7 +269,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { const access: UpdateAccessDto = { alias: this.accessForm.get('alias')?.value, - filters: filters.length > 0 ? filters : undefined, + filters: this.hasLoadedFilters ? filters : undefined, granteeUserId: this.accessForm.get('granteeUserId')?.value, id: accessId, permissions: [this.accessForm.get('permissions')?.value] diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index 1736aa9fc..d7857dca9 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -29,21 +29,45 @@ Private Public - - -
- -
- - Permission - - Restricted view - @if (accessForm.get('type')?.value === 'PRIVATE') { - View + @if ( + hasExperimentalFeatures || accessForm.get('type')?.value === 'API' + ) { + API }
+ + @if (accessForm.get('type')?.value !== 'API') { +
+ + Permission + + Restricted view + @if (accessForm.get('type')?.value === 'PRIVATE') { + View + } + + +
+ } + @if (accessForm.get('type')?.value === 'API') { +
+ + Valid until + + + + +
+ } @if (accessForm.get('type')?.value === 'PRIVATE') {
diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index c9d74b6b1..484b289b9 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -1,8 +1,11 @@ import { GfAccessTableComponent } from '@ghostfolio/client/components/access-table/access-table.component'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { CreateAccessDto } from '@ghostfolio/common/dtos'; import { ConfirmationDialogType } from '@ghostfolio/common/enums'; -import { Access, User } from '@ghostfolio/common/interfaces'; +import { + Access, + CreateAccessResponse, + User +} from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { GfFabComponent } from '@ghostfolio/ui/fab'; import { NotificationService } from '@ghostfolio/ui/notifications'; @@ -201,13 +204,23 @@ export class GfUserAccountAccessComponent implements OnInit { width: this.deviceType() === 'mobile' ? '100vw' : '50rem' }); - dialogRef.afterClosed().subscribe((access: CreateAccessDto | null) => { - if (access) { - this.update(); - } + dialogRef + .afterClosed() + .subscribe((response: CreateAccessResponse | null) => { + if (response) { + if (response.apiToken) { + this.notificationService.alert({ + copyValue: response.apiToken, + message: $localize`For security reasons, the API token is only shown once. Please copy it now and store it securely.`, + title: $localize`API token` + }); + } - this.router.navigate(['.'], { relativeTo: this.route }); - }); + this.update(); + } + + this.router.navigate(['.'], { relativeTo: this.route }); + }); } private openUpdateAccessDialog(accessId: string) { @@ -226,6 +239,7 @@ export class GfUserAccountAccessComponent implements OnInit { data: { access: { alias: access.alias, + expiresAt: access.expiresAt, grantee: access.grantee === 'Public' ? undefined : access.grantee, id: access.id, permissions: access.permissions, diff --git a/libs/common/src/lib/dtos/create-access.dto.ts b/libs/common/src/lib/dtos/create-access.dto.ts index d4ba94fd2..2002e0b96 100644 --- a/libs/common/src/lib/dtos/create-access.dto.ts +++ b/libs/common/src/lib/dtos/create-access.dto.ts @@ -1,13 +1,24 @@ import { Filter } from '@ghostfolio/common/interfaces'; -import { AccessPermission } from '@prisma/client'; -import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { AccessPermission, AccessType } from '@prisma/client'; +import { + IsArray, + IsEnum, + IsISO8601, + IsOptional, + IsString, + IsUUID +} from 'class-validator'; export class CreateAccessDto { @IsOptional() @IsString() alias?: string; + @IsISO8601() + @IsOptional() + expiresAt?: string; + @IsArray() @IsOptional() filters?: Filter[]; @@ -19,4 +30,8 @@ export class CreateAccessDto { @IsEnum(AccessPermission, { each: true }) @IsOptional() permissions?: AccessPermission[]; + + @IsEnum(AccessType) + @IsOptional() + type?: AccessType; } diff --git a/libs/common/src/lib/interfaces/access.interface.ts b/libs/common/src/lib/interfaces/access.interface.ts index f3e74e756..111b49924 100644 --- a/libs/common/src/lib/interfaces/access.interface.ts +++ b/libs/common/src/lib/interfaces/access.interface.ts @@ -1,11 +1,10 @@ -import { AccessType } from '@ghostfolio/common/types'; - -import { AccessPermission } from '@prisma/client'; +import { AccessPermission, AccessType } from '@prisma/client'; import { AccessSettings } from './access-settings.interface'; export interface Access { alias?: string; + expiresAt?: Date; grantee?: string; id: string; permissions: AccessPermission[]; diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index deec60ab7..f564cb548 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -48,11 +48,13 @@ import type { AdminUsersResponse } from './responses/admin-users-response.interf import type { AiPromptResponse } from './responses/ai-prompt-response.interface'; import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface'; +import type { ApiPortfolioResponse } from './responses/api-portfolio-response.interface'; import type { AssetProfileResponse } from './responses/asset-profile-response.interface'; import type { AssetProfilesResponse } from './responses/asset-profiles-response.interface'; import type { AssetResponse } from './responses/asset-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface'; +import type { CreateAccessResponse } from './responses/create-access-response.interface'; import type { CreateStripeCheckoutSessionResponse } from './responses/create-stripe-checkout-session-response.interface'; import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface'; import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface'; @@ -121,6 +123,7 @@ export { AiPromptResponse, AiServiceHealthResponse, ApiKeyResponse, + ApiPortfolioResponse, AssertionCredentialJSON, AssetClassSelectorOption, AssetProfileIdentifier, @@ -134,6 +137,7 @@ export { BenchmarkProperty, BenchmarkResponse, Coupon, + CreateAccessResponse, CreateStripeCheckoutSessionResponse, DataEnhancerHealthResponse, DataProviderGhostfolioAssetProfileResponse, diff --git a/libs/common/src/lib/interfaces/responses/api-portfolio-response.interface.ts b/libs/common/src/lib/interfaces/responses/api-portfolio-response.interface.ts new file mode 100644 index 000000000..934fbbd00 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/api-portfolio-response.interface.ts @@ -0,0 +1,52 @@ +import { + EnhancedSymbolProfile, + PortfolioDetails, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; +import { Market } from '@ghostfolio/common/types'; + +import { Order } from '@prisma/client'; + +export interface ApiPortfolioResponse { + alias?: string; + createdAt: Date; + holdings: { + [symbol: string]: Pick< + PortfolioPosition, + | 'allocationInPercentage' + | 'assetProfile' + | 'dateOfFirstActivity' + | 'markets' + | 'netPerformancePercentWithCurrencyEffect' + | 'quantity' + | 'valueInBaseCurrency' + | 'valueInPercentage' + >; + }; + latestActivities: (Pick< + Order, + 'currency' | 'date' | 'fee' | 'quantity' | 'type' | 'unitPrice' + > & { + SymbolProfile?: EnhancedSymbolProfile; + value: number; + valueInBaseCurrency: number; + })[]; + markets: { + [key in Market]: Pick< + NonNullable[key], + 'id' | 'valueInBaseCurrency' | 'valueInPercentage' + >; + }; + performance: { + '1d': { + relativeChange: number; + }; + max: { + relativeChange: number; + }; + ytd: { + relativeChange: number; + }; + }; + totalValueInBaseCurrency: number; +} diff --git a/libs/common/src/lib/interfaces/responses/create-access-response.interface.ts b/libs/common/src/lib/interfaces/responses/create-access-response.interface.ts new file mode 100644 index 000000000..ec8afa668 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/create-access-response.interface.ts @@ -0,0 +1,5 @@ +import { Access } from '@prisma/client'; + +export interface CreateAccessResponse extends Omit { + apiToken?: string; +} diff --git a/libs/common/src/lib/types/access-type.type.ts b/libs/common/src/lib/types/access-type.type.ts deleted file mode 100644 index fa8e966aa..000000000 --- a/libs/common/src/lib/types/access-type.type.ts +++ /dev/null @@ -1 +0,0 @@ -export type AccessType = 'PRIVATE' | 'PUBLIC'; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index aa6893bc6..02d0b105e 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -1,4 +1,3 @@ -import type { AccessType } from './access-type.type'; import type { AccessWithGranteeUser } from './access-with-grantee-user.type'; import type { AccountWithPlatform } from './account-with-platform.type'; import type { AccountWithValue } from './account-with-value.type'; @@ -22,11 +21,11 @@ import type { PropertyKey } from './property-key.type'; import type { RequestWithUser } from './request-with-user.type'; import type { SectorName } from './sector-name.type'; import type { SubscriptionOfferKey } from './subscription-offer-key.type'; +import type { UserWithApiAccess } from './user-with-api-access.type'; import type { UserWithSettings } from './user-with-settings.type'; import type { ViewMode } from './view-mode.type'; export type { - AccessType, AccessWithGranteeUser, AccountWithPlatform, AccountWithValue, @@ -50,6 +49,7 @@ export type { RequestWithUser, SectorName, SubscriptionOfferKey, + UserWithApiAccess, UserWithSettings, ViewMode }; diff --git a/libs/common/src/lib/types/user-with-api-access.type.ts b/libs/common/src/lib/types/user-with-api-access.type.ts new file mode 100644 index 000000000..ff1df50a6 --- /dev/null +++ b/libs/common/src/lib/types/user-with-api-access.type.ts @@ -0,0 +1,6 @@ +import { AccessWithGranteeUser } from './access-with-grantee-user.type'; +import { UserWithSettings } from './user-with-settings.type'; + +export type UserWithApiAccess = UserWithSettings & { + apiAccess: AccessWithGranteeUser; +}; diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index 5492794d7..9ffdbd9cb 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -33,6 +33,7 @@ import { AssetResponse, BenchmarkMarketDataDetailsResponse, BenchmarkResponse, + CreateAccessResponse, CreateStripeCheckoutSessionResponse, DataProviderHealthResponse, DataProviderHistoricalResponse, @@ -829,7 +830,7 @@ export class DataService { } public postAccess(aAccess: CreateAccessDto) { - return this.http.post('/api/v1/access', aAccess); + return this.http.post('/api/v1/access', aAccess); } public postAccount(aAccount: CreateAccountDto) { diff --git a/prisma/migrations/20260706000000_added_type_to_access/migration.sql b/prisma/migrations/20260706000000_added_type_to_access/migration.sql new file mode 100644 index 000000000..6a0fb6a08 --- /dev/null +++ b/prisma/migrations/20260706000000_added_type_to_access/migration.sql @@ -0,0 +1,13 @@ +-- CreateEnum +CREATE TYPE "AccessType" AS ENUM ('API', 'PRIVATE', 'PUBLIC'); + +-- AlterTable +ALTER TABLE "Access" ADD COLUMN "expiresAt" TIMESTAMP(3), +ADD COLUMN "hashedApiToken" TEXT, +ADD COLUMN "type" "AccessType" NOT NULL DEFAULT 'PRIVATE'; + +-- Backfill type of existing accesses +UPDATE "Access" SET "type" = 'PUBLIC' WHERE "granteeUserId" IS NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "Access_hashedApiToken_key" ON "Access"("hashedApiToken"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 733399506..77bcedc11 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,16 +9,19 @@ datasource db { } model Access { - alias String? - createdAt DateTime @default(now()) - granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) - granteeUserId String? - id String @id @default(uuid()) - permissions AccessPermission[] @default([READ_RESTRICTED]) - settings Json @default("{}") - updatedAt DateTime @updatedAt - userId String - user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id]) + alias String? + createdAt DateTime @default(now()) + expiresAt DateTime? + granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) + granteeUserId String? + hashedApiToken String? @unique + id String @id @default(uuid()) + permissions AccessPermission[] @default([READ_RESTRICTED]) + settings Json @default("{}") + type AccessType @default(PRIVATE) + updatedAt DateTime @updatedAt + userId String + user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id]) @@index([alias]) @@index([granteeUserId]) @@ -313,6 +316,12 @@ enum AccessPermission { READ_RESTRICTED } +enum AccessType { + API + PRIVATE + PUBLIC +} + enum AssetClass { ALTERNATIVE_INVESTMENT COMMODITY