From 60e6b316889828af37c21d5514b514d4d15476ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Mon, 22 Sep 2025 20:20:30 +0200 Subject: [PATCH] Feature/add update access functionality and edit dialog support --- apps/api/src/app/access/access.controller.ts | 61 ++++++++++++++ apps/api/src/app/access/access.service.ts | 10 +++ apps/api/src/app/access/update-access.dto.ts | 16 ++++ .../access-table/access-table.component.html | 8 +- .../access-table/access-table.component.ts | 7 ++ ...reate-or-update-access-dialog.component.ts | 79 ++++++++++++++++++- .../create-or-update-access-dialog.html | 16 +++- .../interfaces/interfaces.ts | 1 + .../user-account-access.component.ts | 39 +++++++++ .../user-account-access.html | 1 + apps/client/src/app/services/data.service.ts | 13 +++ 11 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/app/access/update-access.dto.ts diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index bc2d22e51..a24ac5d1a 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -14,6 +14,7 @@ import { Inject, Param, Post, + Put, UseGuards } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; @@ -23,6 +24,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { AccessService } from './access.service'; import { CreateAccessDto } from './create-access.dto'; +import { UpdateAccessDto } from './update-access.dto'; @Controller('access') export class AccessController { @@ -66,6 +68,21 @@ export class AccessController { ); } + @Get(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getAccess(@Param('id') id: string): Promise { + const access = await this.accessService.access({ id }); + + if (!access || access.userId !== this.request.user.id) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return access; + } + @HasPermission(permissions.createAccess) @Post() @UseGuards(AuthGuard('jwt'), HasPermissionGuard) @@ -99,6 +116,50 @@ export class AccessController { } } + @Put(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateAccess( + @Param('id') id: string, + @Body() data: UpdateAccessDto + ): Promise { + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const access = await this.accessService.access({ id }); + + if (!access || access.userId !== this.request.user.id) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + try { + return this.accessService.updateAccess( + { id }, + { + alias: data.alias, + granteeUser: data.granteeUserId + ? { connect: { id: data.granteeUserId } } + : { disconnect: true }, + permissions: data.permissions + } + ); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } + @Delete(':id') @HasPermission(permissions.deleteAccess) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index 8403cdc09..995e27ae9 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -52,4 +52,14 @@ export class AccessService { where }); } + + public async updateAccess( + where: Prisma.AccessWhereUniqueInput, + data: Prisma.AccessUpdateInput + ): Promise { + return this.prismaService.access.update({ + where, + data + }); + } } diff --git a/apps/api/src/app/access/update-access.dto.ts b/apps/api/src/app/access/update-access.dto.ts new file mode 100644 index 000000000..5e8799747 --- /dev/null +++ b/apps/api/src/app/access/update-access.dto.ts @@ -0,0 +1,16 @@ +import { AccessPermission } from '@prisma/client'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UpdateAccessDto { + @IsOptional() + @IsString() + alias?: string; + + @IsOptional() + @IsUUID() + granteeUserId?: string; + + @IsEnum(AccessPermission, { each: true }) + @IsOptional() + permissions?: AccessPermission[]; +} diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html index be374db9c..a9734ef85 100644 --- a/apps/client/src/app/components/access-table/access-table.component.html +++ b/apps/client/src/app/components/access-table/access-table.component.html @@ -65,6 +65,12 @@ + @if (element.type === 'PUBLIC') { -
} +
diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts index b7850fb38..653ee66fd 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts @@ -2,4 +2,5 @@ import { Access } from '@ghostfolio/common/interfaces'; export interface CreateOrUpdateAccessDialogParams { access: Access; + accessId?: string; } diff --git a/apps/client/src/app/components/user-account-access/user-account-access.component.ts b/apps/client/src/app/components/user-account-access/user-account-access.component.ts index c7959486b..c6a66ed89 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.component.ts +++ b/apps/client/src/app/components/user-account-access/user-account-access.component.ts @@ -138,6 +138,10 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit { }); } + public onEditAccess(aId: string) { + this.openEditAccessDialog(aId); + } + public onGenerateAccessToken() { this.notificationService.confirm({ confirmFn: () => { @@ -200,6 +204,41 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit { }); } + private openEditAccessDialog(accessId: string) { + // Fetch the access details first + this.dataService + .fetchAccess(accessId) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe({ + next: (accessDetails) => { + const dialogRef = this.dialog.open(GfCreateOrUpdateAccessDialog, { + data: { + access: { + alias: accessDetails.alias, + permissions: accessDetails.permissions, + type: accessDetails.granteeUser ? 'PRIVATE' : 'PUBLIC', + grantee: accessDetails.granteeUser?.id || null + }, + accessId: accessId + }, + height: this.deviceType === 'mobile' ? '98vh' : undefined, + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.update(); + } + }); + }, + error: () => { + this.notificationService.alert({ + title: $localize`Oops! Could not load access details.` + }); + } + }); + } + private update() { this.accessesGet = this.user.access.map(({ alias, id, permissions }) => { return { diff --git a/apps/client/src/app/components/user-account-access/user-account-access.html b/apps/client/src/app/components/user-account-access/user-account-access.html index 2979fd6fa..c0e2fd741 100644 --- a/apps/client/src/app/components/user-account-access/user-account-access.html +++ b/apps/client/src/app/components/user-account-access/user-account-access.html @@ -64,6 +64,7 @@ [showActions]="hasPermissionToDeleteAccess" [user]="user" (accessDeleted)="onDeleteAccess($event)" + (accessEdited)="onEditAccess($event)" /> @if (hasPermissionToCreateAccess) {
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 820ad5e3c..eb6f55cbb 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -1,4 +1,5 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; +import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto'; import { CreateAccountBalanceDto } from '@ghostfolio/api/app/account-balance/create-account-balance.dto'; import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto'; import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto'; @@ -53,6 +54,7 @@ import { } from '@ghostfolio/common/interfaces'; import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; import type { + AccessWithGranteeUser, AccountWithValue, AiPromptMode, DateRange, @@ -334,6 +336,10 @@ export class DataService { return this.http.delete(`/api/v1/watchlist/${dataSource}/${symbol}`); } + public fetchAccess(id: string) { + return this.http.get(`/api/v1/access/${id}`); + } + public fetchAccesses() { return this.http.get('/api/v1/access'); } @@ -743,6 +749,13 @@ export class DataService { return this.http.post('/api/v1/access', aAccess); } + public putAccess(id: string, aAccess: UpdateAccessDto) { + return this.http.put( + `/api/v1/access/${id}`, + aAccess + ); + } + public postAccount(aAccount: CreateAccountDto) { return this.http.post('/api/v1/account', aAccount); }