Browse Source

Merge c5e780f10f into b0747b025c

pull/7262/merge
Thomas Kaul 6 days ago
committed by GitHub
parent
commit
c7fc99df62
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 108
      apps/api/src/app/access/access.controller.ts
  3. 3
      apps/api/src/app/access/access.module.ts
  4. 37
      apps/api/src/app/access/access.service.ts
  5. 2
      apps/api/src/app/app.module.ts
  6. 85
      apps/api/src/app/auth/api-access-token.strategy.ts
  7. 4
      apps/api/src/app/auth/auth.module.ts
  8. 83
      apps/api/src/app/endpoints/api/api.controller.ts
  9. 12
      apps/api/src/app/endpoints/api/api.module.ts
  10. 99
      apps/api/src/app/endpoints/public/public.controller.ts
  11. 92
      apps/api/src/app/portfolio/portfolio.service.ts
  12. 7
      apps/api/src/app/user/user.service.ts
  13. 8
      apps/api/src/helper/hash.helper.ts
  14. 1
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  15. 22
      apps/api/src/services/api-key/api-key.service.ts
  16. 2
      apps/api/src/services/impersonation/impersonation.service.ts
  17. 11
      apps/client/src/app/components/access-table/access-table.component.html
  18. 7
      apps/client/src/app/components/access-table/access-table.component.ts
  19. 50
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  20. 46
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  21. 30
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  22. 19
      libs/common/src/lib/dtos/create-access.dto.ts
  23. 5
      libs/common/src/lib/interfaces/access.interface.ts
  24. 4
      libs/common/src/lib/interfaces/index.ts
  25. 52
      libs/common/src/lib/interfaces/responses/api-portfolio-response.interface.ts
  26. 5
      libs/common/src/lib/interfaces/responses/create-access-response.interface.ts
  27. 1
      libs/common/src/lib/types/access-type.type.ts
  28. 4
      libs/common/src/lib/types/index.ts
  29. 6
      libs/common/src/lib/types/user-with-api-access.type.ts
  30. 3
      libs/ui/src/lib/services/data.service.ts
  31. 13
      prisma/migrations/20260706000000_added_type_to_access/migration.sql
  32. 29
      prisma/schema.prisma

1
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 - Added support for a copy-to-clipboard action in the alert dialog component
### Changed ### Changed

108
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { SubscriptionType } from '@ghostfolio/common/enums'; 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 { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -21,8 +25,14 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { omit } from 'lodash';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
@ -46,15 +56,26 @@ export class AccessController {
}); });
return accessesWithGranteeUser.map( 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) { if (granteeUser) {
return { return {
alias, alias,
id, id,
permissions, permissions,
type,
grantee: granteeUser?.id, grantee: granteeUser?.id,
settings: settings as AccessSettings, settings: settings as AccessSettings
type: 'PRIVATE'
}; };
} }
@ -62,9 +83,9 @@ export class AccessController {
alias, alias,
id, id,
permissions, permissions,
type,
grantee: 'Public', grantee: 'Public',
settings: settings as AccessSettings, settings: settings as AccessSettings
type: 'PUBLIC'
}; };
} }
); );
@ -75,7 +96,7 @@ export class AccessController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createAccess( public async createAccess(
@Body() data: CreateAccessDto @Body() data: CreateAccessDto
): Promise<AccessModel> { ): Promise<CreateAccessResponse> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic 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 { 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, alias: data.alias || undefined,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: isApiAccess ? [AccessPermission.READ] : data.permissions,
settings: this.accessService.buildSettings(data.filters), settings: this.accessService.buildSettings(data.filters),
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
return {
...omit(access, 'hashedApiToken'),
apiToken
};
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),
@ -107,7 +161,9 @@ export class AccessController {
@Delete(':id') @Delete(':id')
@HasPermission(permissions.deleteAccess) @HasPermission(permissions.deleteAccess)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> { public async deleteAccess(
@Param('id') id: string
): Promise<Omit<AccessModel, 'hashedApiToken'>> {
const originalAccess = await this.accessService.access({ const originalAccess = await this.accessService.access({
id, id,
userId: this.request.user.id userId: this.request.user.id
@ -120,9 +176,11 @@ export class AccessController {
); );
} }
return this.accessService.deleteAccess({ const access = await this.accessService.deleteAccess({
id id
}); });
return omit(access, 'hashedApiToken');
} }
@HasPermission(permissions.updateAccess) @HasPermission(permissions.updateAccess)
@ -131,7 +189,7 @@ export class AccessController {
public async updateAccess( public async updateAccess(
@Body() data: UpdateAccessDto, @Body() data: UpdateAccessDto,
@Param('id') id: string @Param('id') id: string
): Promise<AccessModel> { ): Promise<Omit<AccessModel, 'hashedApiToken'>> {
if ( if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === SubscriptionType.Basic 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 { try {
return this.accessService.updateAccess({ const access = await this.accessService.updateAccess({
data: { data: {
alias: data.alias, alias: data.alias,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: { disconnect: true }, : undefined,
permissions: data.permissions, permissions: isApiAccess ? undefined : data.permissions,
settings: this.accessService.buildSettings(data.filters) settings: data.filters
? this.accessService.buildSettings(data.filters)
: undefined
}, },
where: { id } where: { id }
}); });
return omit(access, 'hashedApiToken');
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),

3
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -9,7 +10,7 @@ import { AccessService } from './access.service';
@Module({ @Module({
controllers: [AccessController], controllers: [AccessController],
exports: [AccessService], exports: [AccessService],
imports: [ConfigurationModule, PrismaModule], imports: [ApiKeyModule, ConfigurationModule, PrismaModule],
providers: [AccessService] providers: [AccessService]
}) })
export class AccessModule {} export class AccessModule {}

37
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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { AccessSettings, Filter } from '@ghostfolio/common/interfaces'; import { AccessSettings, Filter } from '@ghostfolio/common/interfaces';
import { AccessWithGranteeUser } from '@ghostfolio/common/types'; import { AccessWithGranteeUser } from '@ghostfolio/common/types';
@ -7,7 +10,11 @@ import { Access, Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class AccessService { 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( public async access(
accessWhereInput: Prisma.AccessWhereInput 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( public async deleteAccess(
where: Prisma.AccessWhereUniqueInput where: Prisma.AccessWhereUniqueInput
): Promise<Access> { ): Promise<Access> {
@ -60,6 +76,17 @@ export class AccessService {
}); });
} }
public async getAccessByApiToken(
apiToken: string
): Promise<AccessWithGranteeUser | null> {
return this.prismaService.access.findUnique({
include: {
granteeUser: true
},
where: { hashedApiToken: this.hashApiToken(apiToken) }
});
}
public async updateAccess({ public async updateAccess({
data, data,
where where
@ -72,4 +99,12 @@ export class AccessService {
where where
}); });
} }
private hashApiToken(apiToken: string) {
// Rotating ACCESS_TOKEN_SALT invalidates all stored api tokens
return createSha512HmacHash(
apiToken,
this.configurationService.get('ACCESS_TOKEN_SALT')
);
}
} }

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

@ -38,6 +38,7 @@ import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module'; import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.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 { AssetProfilesModule } from './endpoints/asset-profiles/asset-profiles.module';
import { AssetsModule } from './endpoints/assets/assets.module'; import { AssetsModule } from './endpoints/assets/assets.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
@ -69,6 +70,7 @@ import { UserModule } from './user/user.module';
AccountModule, AccountModule,
ActivitiesModule, ActivitiesModule,
AiModule, AiModule,
ApiModule,
ApiKeysModule, ApiKeysModule,
AssetProfilesModule, AssetProfilesModule,
AssetModule, AssetModule,

85
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<UserWithApiAccess> {
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 });
}
}

4
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 { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
@ -14,6 +15,7 @@ import { Logger, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import type { StrategyOptions } from 'passport-openidconnect'; import type { StrategyOptions } from 'passport-openidconnect';
import { ApiAccessTokenStrategy } from './api-access-token.strategy';
import { ApiKeyStrategy } from './api-key.strategy'; import { ApiKeyStrategy } from './api-key.strategy';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@ -24,6 +26,7 @@ import { OidcStrategy } from './oidc.strategy';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
imports: [ imports: [
AccessModule,
ConfigurationModule, ConfigurationModule,
FetchModule, FetchModule,
JwtModule.register({ JwtModule.register({
@ -36,6 +39,7 @@ import { OidcStrategy } from './oidc.strategy';
UserModule UserModule
], ],
providers: [ providers: [
ApiAccessTokenStrategy,
ApiKeyService, ApiKeyService,
ApiKeyStrategy, ApiKeyStrategy,
AuthDeviceService, AuthDeviceService,

83
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<ApiPortfolioResponse> {
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;
}
}

12
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 {}

99
apps/api/src/app/endpoints/public/public.controller.ts

@ -1,5 +1,4 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service'; 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 { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.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'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
@ -21,11 +20,7 @@ import {
Param, Param,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { import { AccessType, AssetClass, AssetSubClass } from '@prisma/client';
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -33,7 +28,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
export class PublicController { export class PublicController {
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@ -48,7 +42,8 @@ export class PublicController {
): Promise<PublicPortfolioResponse> { ): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({ const access = await this.accessService.access({
granteeUserId: null, granteeUserId: null,
id: accessId id: accessId,
type: AccessType.PUBLIC
}); });
if (!access) { if (!access) {
@ -70,69 +65,13 @@ export class PublicController {
const { filters } = (access.settings ?? {}) as AccessSettings; const { filters } = (access.settings ?? {}) as AccessSettings;
const [ const { createdAt, holdings, latestActivities, markets, performance } =
{ createdAt, holdings, markets }, await this.portfolioService.getPortfolioOverview({
{ performance: performance1d },
{ performance: performanceMax },
{ performance: performanceYtd }
] = await Promise.all([
this.portfolioService.getDetails({
filters, filters,
impersonationId: access.userId, impersonationId: access.userId,
userId: user.id, userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
withMarkets: true userId: user.id
}), });
...['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
};
}
);
Object.values(markets ?? {}).forEach((market) => { Object.values(markets ?? {}).forEach((market) => {
delete market.valueInBaseCurrency; delete market.valueInBaseCurrency;
@ -141,24 +80,16 @@ export class PublicController {
const publicPortfolioResponse: PublicPortfolioResponse = { const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt, createdAt,
hasDetails, hasDetails,
latestActivities,
markets, markets,
performance,
alias: access.alias, alias: access.alias,
holdings: {}, holdings: {},
performance: { // Experimental
'1d': { latestActivities: this.configurationService.get(
relativeChange: 'ENABLE_FEATURE_SUBSCRIPTION'
performance1d.netPerformancePercentageWithCurrencyEffect )
}, ? []
max: { : latestActivities
relativeChange:
performanceMax.netPerformancePercentageWithCurrencyEffect
},
ytd: {
relativeChange:
performanceYtd.netPerformancePercentageWithCurrencyEffect
}
}
}; };
const totalValue = getSum( const totalValue = getSum(

92
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({ public async getReport({
impersonationId, impersonationId,
userId userId

7
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 { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
import { createSha512HmacHash } from '@ghostfolio/api/helper/hash.helper';
import { getRandomString } from '@ghostfolio/api/helper/string.helper'; import { getRandomString } from '@ghostfolio/api/helper/string.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
@ -54,7 +55,6 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma, Role, Settings, User } from '@prisma/client'; import { Prisma, Role, Settings, User } from '@prisma/client';
import { differenceInDays, subDays } from 'date-fns'; import { differenceInDays, subDays } from 'date-fns';
import { without } from 'lodash'; import { without } from 'lodash';
import { createHmac } from 'node:crypto';
@Injectable() @Injectable()
export class UserService { export class UserService {
@ -80,10 +80,7 @@ export class UserService {
password: string; password: string;
salt: string; salt: string;
}): string { }): string {
const hash = createHmac('sha512', salt); return createSha512HmacHash(password, salt);
hash.update(password);
return hash.digest('hex');
} }
public generateAccessToken({ userId }: { userId: string }) { public generateAccessToken({ userId }: { userId: string }) {

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

1
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[*].assetProfile.dataSource',
'holdings[*].dataSource', 'holdings[*].dataSource',
'items[*].dataSource', 'items[*].dataSource',
'latestActivities[*].SymbolProfile.dataSource',
'SymbolProfile.dataSource', 'SymbolProfile.dataSource',
'watchlist[*].dataSource' 'watchlist[*].dataSource'
] ]

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

@ -29,6 +29,17 @@ export class ApiKeyService {
return { apiKey }; 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) { public async getUserByApiKey(apiKey: string) {
const hashedKey = this.hashApiKey(apiKey); const hashedKey = this.hashApiKey(apiKey);
@ -49,15 +60,4 @@ export class ApiKeyService {
this.algorithm this.algorithm
).toString('hex'); ).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('-');
}
} }

2
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 { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AccessType } from '@prisma/client';
@Injectable() @Injectable()
export class ImpersonationService { export class ImpersonationService {
@ -36,6 +37,7 @@ export class ImpersonationService {
const accessObject = await this.prismaService.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { where: {
granteeUserId: null, granteeUserId: null,
type: AccessType.PUBLIC,
user: { id: aId } user: { id: aId }
} }
}); });

11
apps/client/src/app/components/access-table/access-table.component.html

@ -32,6 +32,17 @@
<ng-container matColumnDef="details"> <ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.type === 'API') {
<div>
<code>GET {{ baseUrl }}/api/v1/api/portfolio</code>
</div>
@if (element.expiresAt) {
<div>
<ng-container i18n>Valid until</ng-container>
{{ element.expiresAt | date: defaultDateFormat() }}
</div>
}
}
@if (element.type === 'PUBLIC') { @if (element.type === 'PUBLIC') {
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
<ion-icon class="mr-1" name="link-outline" /> <ion-icon class="mr-1" name="link-outline" />

7
apps/client/src/app/components/access-table/access-table.component.ts

@ -1,9 +1,11 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
import { DatePipe } from '@angular/common';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -36,6 +38,7 @@ import ms from 'ms';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ClipboardModule, ClipboardModule,
DatePipe,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
@ -58,6 +61,10 @@ export class GfAccessTableComponent {
protected readonly baseUrl = window.location.origin; protected readonly baseUrl = window.location.origin;
protected readonly dataSource = new MatTableDataSource<Access>(); protected readonly dataSource = new MatTableDataSource<Access>();
protected readonly defaultDateFormat = computed(() => {
return getDateFormatString(this.user()?.settings?.locale);
});
protected readonly displayedColumns = computed(() => { protected readonly displayedColumns = computed(() => {
const columns = ['alias', 'grantee', 'type', 'details']; const columns = ['alias', 'grantee', 'type', 'details'];

50
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 Validators
} from '@angular/forms'; } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { import {
MAT_DIALOG_DATA, MAT_DIALOG_DATA,
MatDialogModule, MatDialogModule,
@ -40,7 +41,7 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { AccessPermission } from '@prisma/client'; import { AccessPermission, AccessType } from '@prisma/client';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { EMPTY, catchError } from 'rxjs'; import { EMPTY, catchError } from 'rxjs';
@ -53,6 +54,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
FormsModule, FormsModule,
GfPortfolioFilterFormComponent, GfPortfolioFilterFormComponent,
MatButtonModule, MatButtonModule,
MatDatepickerModule,
MatDialogModule, MatDialogModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
@ -70,9 +72,11 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public tags: Filter[] = []; public tags: Filter[] = [];
protected accessForm: FormGroup; protected accessForm: FormGroup;
protected hasExperimentalFeatures = false;
protected readonly minExpiresAtDate = new Date();
protected readonly mode: 'create' | 'update'; protected readonly mode: 'create' | 'update';
private hasExperimentalFeatures = false; private hasLoadedFilters = false;
private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly changeDetectorRef = inject(ChangeDetectorRef);
@ -95,21 +99,27 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public get canApplyFilters() { public get canApplyFilters() {
return ( return (
this.accessForm?.get('type')?.value === 'PUBLIC' && ['API', 'PUBLIC'].includes(this.accessForm?.get('type')?.value) &&
this.hasExperimentalFeatures this.hasExperimentalFeatures
); );
} }
public ngOnInit() { public ngOnInit() {
const access = this.data?.access; const access = this.data?.access;
const isPublic = access?.type === 'PUBLIC'; const isPrivate = (access?.type ?? 'PRIVATE') === 'PRIVATE';
this.accessForm = this.formBuilder.group({ this.accessForm = this.formBuilder.group({
alias: [access?.alias ?? ''], alias: [access?.alias ?? ''],
expiresAt: [
{
disabled: this.mode === 'update',
value: access?.expiresAt ?? null
}
],
filters: [null], filters: [null],
granteeUserId: [ granteeUserId: [
access?.grantee ?? null, access?.grantee ?? null,
isPublic ? null : Validators.required isPrivate ? Validators.required : null
], ],
permissions: [ permissions: [
access?.permissions[0] ?? AccessPermission.READ_RESTRICTED, access?.permissions[0] ?? AccessPermission.READ_RESTRICTED,
@ -138,6 +148,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
.get('type') .get('type')
?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((accessType) => { .subscribe((accessType) => {
const expiresAtControl = this.accessForm.get('expiresAt');
const granteeUserIdControl = this.accessForm.get('granteeUserId'); const granteeUserIdControl = this.accessForm.get('granteeUserId');
const permissionsControl = this.accessForm.get('permissions'); const permissionsControl = this.accessForm.get('permissions');
@ -147,9 +158,18 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
} else { } else {
granteeUserIdControl?.clearValidators(); granteeUserIdControl?.clearValidators();
granteeUserIdControl?.setValue(null); 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(); granteeUserIdControl?.updateValueAndValidity();
@ -181,8 +201,14 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
private async createAccess() { private async createAccess() {
const filters = this.buildFilters(); const filters = this.buildFilters();
const expiresAt: Date | null = this.accessForm.get('expiresAt')?.value;
const type: AccessType = this.accessForm.get('type')?.value;
const access: CreateAccessDto = { const access: CreateAccessDto = {
type,
alias: this.accessForm.get('alias')?.value, alias: this.accessForm.get('alias')?.value,
expiresAt:
type === 'API' && expiresAt ? expiresAt.toISOString() : undefined,
filters: filters.length > 0 ? filters : undefined, filters: filters.length > 0 ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
permissions: [this.accessForm.get('permissions')?.value] permissions: [this.accessForm.get('permissions')?.value]
@ -209,8 +235,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}), }),
takeUntilDestroyed(this.destroyRef) takeUntilDestroyed(this.destroyRef)
) )
.subscribe(() => { .subscribe((response) => {
this.dialogRef.close(access); this.dialogRef.close(response);
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@ -226,6 +252,8 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.updateFiltersFormControl(this.data.access?.settings?.filters); this.updateFiltersFormControl(this.data.access?.settings?.filters);
this.hasLoadedFilters = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
@ -241,7 +269,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
const access: UpdateAccessDto = { const access: UpdateAccessDto = {
alias: this.accessForm.get('alias')?.value, alias: this.accessForm.get('alias')?.value,
filters: filters.length > 0 ? filters : undefined, filters: this.hasLoadedFilters ? filters : undefined,
granteeUserId: this.accessForm.get('granteeUserId')?.value, granteeUserId: this.accessForm.get('granteeUserId')?.value,
id: accessId, id: accessId,
permissions: [this.accessForm.get('permissions')?.value] permissions: [this.accessForm.get('permissions')?.value]

46
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -29,21 +29,45 @@
<mat-select formControlName="type"> <mat-select formControlName="type">
<mat-option i18n value="PRIVATE">Private</mat-option> <mat-option i18n value="PRIVATE">Private</mat-option>
<mat-option i18n value="PUBLIC">Public</mat-option> <mat-option i18n value="PUBLIC">Public</mat-option>
</mat-select> @if (
</mat-form-field> hasExperimentalFeatures || accessForm.get('type')?.value === 'API'
</div> ) {
<mat-option value="API">API</mat-option>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Permission</mat-label>
<mat-select formControlName="permissions">
<mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option>
@if (accessForm.get('type')?.value === 'PRIVATE') {
<mat-option i18n value="READ">View</mat-option>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>
@if (accessForm.get('type')?.value !== 'API') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Permission</mat-label>
<mat-select formControlName="permissions">
<mat-option i18n value="READ_RESTRICTED"
>Restricted view</mat-option
>
@if (accessForm.get('type')?.value === 'PRIVATE') {
<mat-option i18n value="READ">View</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
@if (accessForm.get('type')?.value === 'API') {
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Valid until</mat-label>
<input
formControlName="expiresAt"
matInput
[matDatepicker]="expiresAtDatepicker"
[min]="minExpiresAtDate"
/>
<mat-datepicker-toggle matSuffix [for]="expiresAtDatepicker" />
<mat-datepicker #expiresAtDatepicker />
</mat-form-field>
</div>
}
@if (accessForm.get('type')?.value === 'PRIVATE') { @if (accessForm.get('type')?.value === 'PRIVATE') {
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">

30
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 { GfAccessTableComponent } from '@ghostfolio/client/components/access-table/access-table.component';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccessDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; 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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
@ -201,13 +204,23 @@ export class GfUserAccountAccessComponent implements OnInit {
width: this.deviceType() === 'mobile' ? '100vw' : '50rem' width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef.afterClosed().subscribe((access: CreateAccessDto | null) => { dialogRef
if (access) { .afterClosed()
this.update(); .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) { private openUpdateAccessDialog(accessId: string) {
@ -226,6 +239,7 @@ export class GfUserAccountAccessComponent implements OnInit {
data: { data: {
access: { access: {
alias: access.alias, alias: access.alias,
expiresAt: access.expiresAt,
grantee: access.grantee === 'Public' ? undefined : access.grantee, grantee: access.grantee === 'Public' ? undefined : access.grantee,
id: access.id, id: access.id,
permissions: access.permissions, permissions: access.permissions,

19
libs/common/src/lib/dtos/create-access.dto.ts

@ -1,13 +1,24 @@
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { AccessPermission } from '@prisma/client'; import { AccessPermission, AccessType } from '@prisma/client';
import { IsArray, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import {
IsArray,
IsEnum,
IsISO8601,
IsOptional,
IsString,
IsUUID
} from 'class-validator';
export class CreateAccessDto { export class CreateAccessDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
alias?: string; alias?: string;
@IsISO8601()
@IsOptional()
expiresAt?: string;
@IsArray() @IsArray()
@IsOptional() @IsOptional()
filters?: Filter[]; filters?: Filter[];
@ -19,4 +30,8 @@ export class CreateAccessDto {
@IsEnum(AccessPermission, { each: true }) @IsEnum(AccessPermission, { each: true })
@IsOptional() @IsOptional()
permissions?: AccessPermission[]; permissions?: AccessPermission[];
@IsEnum(AccessType)
@IsOptional()
type?: AccessType;
} }

5
libs/common/src/lib/interfaces/access.interface.ts

@ -1,11 +1,10 @@
import { AccessType } from '@ghostfolio/common/types'; import { AccessPermission, AccessType } from '@prisma/client';
import { AccessPermission } from '@prisma/client';
import { AccessSettings } from './access-settings.interface'; import { AccessSettings } from './access-settings.interface';
export interface Access { export interface Access {
alias?: string; alias?: string;
expiresAt?: Date;
grantee?: string; grantee?: string;
id: string; id: string;
permissions: AccessPermission[]; permissions: AccessPermission[];

4
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 { AiPromptResponse } from './responses/ai-prompt-response.interface';
import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface'; import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface';
import type { ApiKeyResponse } from './responses/api-key-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 { AssetProfileResponse } from './responses/asset-profile-response.interface';
import type { AssetProfilesResponse } from './responses/asset-profiles-response.interface'; import type { AssetProfilesResponse } from './responses/asset-profiles-response.interface';
import type { AssetResponse } from './responses/asset-response.interface'; import type { AssetResponse } from './responses/asset-response.interface';
import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-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 { CreateStripeCheckoutSessionResponse } from './responses/create-stripe-checkout-session-response.interface';
import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface'; import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface';
import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface'; import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface';
@ -121,6 +123,7 @@ export {
AiPromptResponse, AiPromptResponse,
AiServiceHealthResponse, AiServiceHealthResponse,
ApiKeyResponse, ApiKeyResponse,
ApiPortfolioResponse,
AssertionCredentialJSON, AssertionCredentialJSON,
AssetClassSelectorOption, AssetClassSelectorOption,
AssetProfileIdentifier, AssetProfileIdentifier,
@ -134,6 +137,7 @@ export {
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse,
Coupon, Coupon,
CreateAccessResponse,
CreateStripeCheckoutSessionResponse, CreateStripeCheckoutSessionResponse,
DataEnhancerHealthResponse, DataEnhancerHealthResponse,
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,

52
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<PortfolioDetails['markets']>[key],
'id' | 'valueInBaseCurrency' | 'valueInPercentage'
>;
};
performance: {
'1d': {
relativeChange: number;
};
max: {
relativeChange: number;
};
ytd: {
relativeChange: number;
};
};
totalValueInBaseCurrency: number;
}

5
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<Access, 'hashedApiToken'> {
apiToken?: string;
}

1
libs/common/src/lib/types/access-type.type.ts

@ -1 +0,0 @@
export type AccessType = 'PRIVATE' | 'PUBLIC';

4
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 { AccessWithGranteeUser } from './access-with-grantee-user.type';
import type { AccountWithPlatform } from './account-with-platform.type'; import type { AccountWithPlatform } from './account-with-platform.type';
import type { AccountWithValue } from './account-with-value.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 { RequestWithUser } from './request-with-user.type';
import type { SectorName } from './sector-name.type'; import type { SectorName } from './sector-name.type';
import type { SubscriptionOfferKey } from './subscription-offer-key.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 { UserWithSettings } from './user-with-settings.type';
import type { ViewMode } from './view-mode.type'; import type { ViewMode } from './view-mode.type';
export type { export type {
AccessType,
AccessWithGranteeUser, AccessWithGranteeUser,
AccountWithPlatform, AccountWithPlatform,
AccountWithValue, AccountWithValue,
@ -50,6 +49,7 @@ export type {
RequestWithUser, RequestWithUser,
SectorName, SectorName,
SubscriptionOfferKey, SubscriptionOfferKey,
UserWithApiAccess,
UserWithSettings, UserWithSettings,
ViewMode ViewMode
}; };

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

3
libs/ui/src/lib/services/data.service.ts

@ -33,6 +33,7 @@ import {
AssetResponse, AssetResponse,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse, BenchmarkResponse,
CreateAccessResponse,
CreateStripeCheckoutSessionResponse, CreateStripeCheckoutSessionResponse,
DataProviderHealthResponse, DataProviderHealthResponse,
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
@ -829,7 +830,7 @@ export class DataService {
} }
public postAccess(aAccess: CreateAccessDto) { public postAccess(aAccess: CreateAccessDto) {
return this.http.post<Access>('/api/v1/access', aAccess); return this.http.post<CreateAccessResponse>('/api/v1/access', aAccess);
} }
public postAccount(aAccount: CreateAccountDto) { public postAccount(aAccount: CreateAccountDto) {

13
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");

29
prisma/schema.prisma

@ -9,16 +9,19 @@ datasource db {
} }
model Access { model Access {
alias String? alias String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id]) expiresAt DateTime?
granteeUserId String? granteeUser User? @relation("accessGet", fields: [granteeUserId], onDelete: Cascade, references: [id])
id String @id @default(uuid()) granteeUserId String?
permissions AccessPermission[] @default([READ_RESTRICTED]) hashedApiToken String? @unique
settings Json @default("{}") id String @id @default(uuid())
updatedAt DateTime @updatedAt permissions AccessPermission[] @default([READ_RESTRICTED])
userId String settings Json @default("{}")
user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id]) type AccessType @default(PRIVATE)
updatedAt DateTime @updatedAt
userId String
user User @relation("accessGive", fields: [userId], onDelete: Cascade, references: [id])
@@index([alias]) @@index([alias])
@@index([granteeUserId]) @@index([granteeUserId])
@ -313,6 +316,12 @@ enum AccessPermission {
READ_RESTRICTED READ_RESTRICTED
} }
enum AccessType {
API
PRIVATE
PUBLIC
}
enum AssetClass { enum AssetClass {
ALTERNATIVE_INVESTMENT ALTERNATIVE_INVESTMENT
COMMODITY COMMODITY

Loading…
Cancel
Save