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

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 { 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<AccessModel> {
): Promise<CreateAccessResponse> {
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<AccessModel> {
public async deleteAccess(
@Param('id') id: string
): Promise<Omit<AccessModel, 'hashedApiToken'>> {
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<AccessModel> {
): Promise<Omit<AccessModel, 'hashedApiToken'>> {
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),

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

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

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

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

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 { 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<PublicPortfolioResponse> {
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(

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({
impersonationId,
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 { 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 }) {

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

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

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

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

@ -32,6 +32,17 @@
<ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<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') {
<div class="align-items-center d-flex">
<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 { 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<Access>();
protected readonly defaultDateFormat = computed(() => {
return getDateFormatString(this.user()?.settings?.locale);
});
protected readonly displayedColumns = computed(() => {
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
} 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]

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-option i18n value="PRIVATE">Private</mat-option>
<mat-option i18n value="PUBLIC">Public</mat-option>
</mat-select>
</mat-form-field>
</div>
<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>
@if (
hasExperimentalFeatures || accessForm.get('type')?.value === 'API'
) {
<mat-option value="API">API</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>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') {
<div>
<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 { 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,

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

5
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[];

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

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

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,
BenchmarkMarketDataDetailsResponse,
BenchmarkResponse,
CreateAccessResponse,
CreateStripeCheckoutSessionResponse,
DataProviderHealthResponse,
DataProviderHistoricalResponse,
@ -829,7 +830,7 @@ export class DataService {
}
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) {

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

Loading…
Cancel
Save