Browse Source

Add support for access of type API

pull/7262/head
Thomas Kaul 1 week ago
parent
commit
8695f0b0eb
  1. 92
      apps/api/src/app/access/access.controller.ts
  2. 3
      apps/api/src/app/access/access.module.ts
  3. 39
      apps/api/src/app/access/access.service.ts
  4. 2
      apps/api/src/app/app.module.ts
  5. 85
      apps/api/src/app/auth/api-access-token.strategy.ts
  6. 4
      apps/api/src/app/auth/auth.module.ts
  7. 146
      apps/api/src/app/endpoints/api/api.controller.ts
  8. 51
      apps/api/src/app/endpoints/api/api.module.ts
  9. 4
      apps/api/src/app/endpoints/public/public.controller.ts
  10. 22
      apps/api/src/services/api-key/api-key.service.ts
  11. 2
      apps/api/src/services/impersonation/impersonation.service.ts
  12. 11
      apps/client/src/app/components/access-table/access-table.component.html
  13. 7
      apps/client/src/app/components/access-table/access-table.component.ts
  14. 46
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  15. 46
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  16. 30
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  17. 19
      libs/common/src/lib/dtos/create-access.dto.ts
  18. 5
      libs/common/src/lib/interfaces/access.interface.ts
  19. 4
      libs/common/src/lib/interfaces/index.ts
  20. 52
      libs/common/src/lib/interfaces/responses/api-portfolio-response.interface.ts
  21. 5
      libs/common/src/lib/interfaces/responses/create-access-response.interface.ts
  22. 1
      libs/common/src/lib/types/access-type.type.ts
  23. 4
      libs/common/src/lib/types/index.ts
  24. 6
      libs/common/src/lib/types/user-with-api-access.type.ts
  25. 3
      libs/ui/src/lib/services/data.service.ts
  26. 13
      prisma/migrations/20260706000000_added_type_to_access/migration.sql
  27. 29
      prisma/schema.prisma

92
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,13 @@ 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 { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { omit } from 'lodash';
import { AccessService } from './access.service';
@ -46,15 +55,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 +82,9 @@ export class AccessController {
alias,
id,
permissions,
type,
grantee: 'Public',
settings: settings as AccessSettings,
type: 'PUBLIC'
settings: settings as AccessSettings
};
}
);
@ -75,7 +95,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 +106,39 @@ export class AccessController {
);
}
const isApiAccess = data.type === AccessType.API;
if (isApiAccess && data.granteeUserId) {
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({
hashedApiToken,
alias: data.alias || undefined,
expiresAt: isApiAccess ? data.expiresAt : undefined,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
permissions: data.permissions,
permissions: isApiAccess ? [AccessPermission.READ] : data.permissions,
settings: this.accessService.buildSettings(data.filters),
type:
data.type ??
(data.granteeUserId ? AccessType.PRIVATE : AccessType.PUBLIC),
user: { connect: { id: this.request.user.id } }
});
return {
...omit(access, 'hashedApiToken'),
apiToken
};
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
@ -107,7 +150,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 +165,11 @@ export class AccessController {
);
}
return this.accessService.deleteAccess({
const access = await this.accessService.deleteAccess({
id
});
return omit(access, 'hashedApiToken');
}
@HasPermission(permissions.updateAccess)
@ -131,7 +178,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 +201,31 @@ export class AccessController {
);
}
const isApiAccess = originalAccess.type === AccessType.API;
if (isApiAccess && 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,
: isApiAccess
? undefined
: { disconnect: true },
permissions: isApiAccess ? undefined : data.permissions,
settings: this.accessService.buildSettings(data.filters)
},
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 { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
@ -10,6 +11,6 @@ import { AccessService } from './access.service';
controllers: [AccessController],
exports: [AccessService],
imports: [ConfigurationModule, PrismaModule],
providers: [AccessService]
providers: [AccessService, ApiKeyService]
})
export class AccessModule {}

39
apps/api/src/app/access/access.service.ts

@ -1,13 +1,20 @@
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';
import { Injectable } from '@nestjs/common';
import { Access, Prisma } from '@prisma/client';
import { createHmac } from 'node:crypto';
@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,14 @@ export class AccessService {
where
});
}
private hashApiToken(apiToken: string) {
const hash = createHmac(
'sha512',
this.configurationService.get('ACCESS_TOKEN_SALT')
);
hash.update(apiToken);
return hash.digest('hex');
}
}

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,

146
apps/api/src/app/endpoints/api/api.controller.ts

@ -0,0 +1,146 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
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 { Type as ActivityType } from '@prisma/client';
import { Big } from 'big.js';
@Controller('api')
export class ApiController {
public constructor(
private readonly activitiesService: ActivitiesService,
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, markets },
{ performance: performance1d },
{ performance: performanceMax },
{ performance: performanceYtd }
] = await Promise.all([
this.portfolioService.getDetails({
filters,
impersonationId: apiAccess.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
});
const latestActivities = activities.map(
({
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
}) => {
return {
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice,
value,
valueInBaseCurrency
};
}
);
const totalValueInBaseCurrency = getSum(
Object.values(holdings).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency ?? 0);
})
).toNumber();
const apiPortfolioResponse: ApiPortfolioResponse = {
createdAt,
latestActivities,
markets,
totalValueInBaseCurrency,
alias: apiAccess.alias,
holdings: {},
performance: {
'1d': {
relativeChange:
performance1d.netPerformancePercentageWithCurrencyEffect
},
max: {
relativeChange:
performanceMax.netPerformancePercentageWithCurrencyEffect
},
ytd: {
relativeChange:
performanceYtd.netPerformancePercentageWithCurrencyEffect
}
}
};
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
apiPortfolioResponse.holdings[symbol] = {
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValueInBaseCurrency,
assetProfile: portfolioPosition.assetProfile,
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: portfolioPosition.markets,
netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect,
quantity: portfolioPosition.quantity,
valueInBaseCurrency: portfolioPosition.valueInBaseCurrency,
valueInPercentage:
portfolioPosition.valueInBaseCurrency / totalValueInBaseCurrency
};
}
return apiPortfolioResponse;
}
}

51
apps/api/src/app/endpoints/api/api.module.ts

@ -0,0 +1,51 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { ApiController } from './api.controller';
@Module({
controllers: [ApiController],
imports: [
ActivitiesModule,
BenchmarkModule,
DataProviderModule,
ExchangeRateDataModule,
I18nModule,
ImpersonationModule,
MarketDataModule,
PortfolioSnapshotQueueModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
CurrentRateService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class ApiModule {}

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

@ -22,6 +22,7 @@ import {
UseInterceptors
} from '@nestjs/common';
import {
AccessType,
AssetClass,
AssetSubClass,
Type as ActivityType
@ -48,7 +49,8 @@ export class PublicController {
): Promise<PublicPortfolioResponse> {
const access = await this.accessService.access({
granteeUserId: null,
id: accessId
id: accessId,
type: AccessType.PUBLIC
});
if (!access) {

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'];

46
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,10 +72,10 @@ 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 readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly data =
@ -95,21 +97,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 +146,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 +156,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 +199,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 +233,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);

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])
@ -311,6 +314,12 @@ enum AccessPermission {
READ_RESTRICTED
}
enum AccessType {
API
PRIVATE
PUBLIC
}
enum AssetClass {
ALTERNATIVE_INVESTMENT
COMMODITY

Loading…
Cancel
Save