From 20a756a37606d79a0e7fb9d0a0e3fce6fed5b58e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Mart=C3=ADn?= Date: Mon, 6 Oct 2025 20:51:04 +0200 Subject: [PATCH] Feature/add support to update granted access (#5566) * Add support to update granted access * Update changelog --- CHANGELOG.md | 1 + apps/api/src/app/access/access.controller.ts | 59 +++++++++++++- apps/api/src/app/access/access.service.ts | 19 ++++- apps/api/src/app/access/update-access.dto.ts | 19 +++++ .../access-table/access-table.component.html | 12 +++ .../access-table/access-table.component.ts | 7 ++ ...reate-or-update-access-dialog.component.ts | 77 +++++++++++++++++-- .../create-or-update-access-dialog.html | 20 ++++- .../user-account-access.component.ts | 42 ++++++++++ .../user-account-access.html | 1 + apps/client/src/app/services/data.service.ts | 5 ++ libs/common/src/lib/permissions.ts | 3 + 12 files changed, 248 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/app/access/update-access.dto.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b1fa120..a595800e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added support to edit a granted access (experimental) - Added support for a date range query parameter in the data gathering endpoint - Added a _Storybook_ story for the activities table component diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index bc2d22e51..cb1e2d4af 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 { @@ -39,7 +41,7 @@ export class AccessController { include: { granteeUser: true }, - orderBy: { granteeUserId: 'asc' }, + orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }], where: { userId: this.request.user.id } }); @@ -103,9 +105,12 @@ export class AccessController { @HasPermission(permissions.deleteAccess) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async deleteAccess(@Param('id') id: string): Promise { - const access = await this.accessService.access({ id }); + const originalAccess = await this.accessService.access({ + id, + userId: this.request.user.id + }); - if (!access || access.userId !== this.request.user.id) { + if (!originalAccess) { throw new HttpException( getReasonPhrase(StatusCodes.FORBIDDEN), StatusCodes.FORBIDDEN @@ -116,4 +121,52 @@ export class AccessController { id }); } + + @HasPermission(permissions.updateAccess) + @Put(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateAccess( + @Body() data: UpdateAccessDto, + @Param('id') id: string + ): Promise { + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const originalAccess = await this.accessService.access({ + id, + userId: this.request.user.id + }); + + if (!originalAccess) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + try { + return this.accessService.updateAccess({ + data: { + alias: data.alias, + granteeUser: data.granteeUserId + ? { connect: { id: data.granteeUserId } } + : { disconnect: true }, + permissions: data.permissions + }, + where: { id } + }); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } } diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts index 8403cdc09..70e46dc36 100644 --- a/apps/api/src/app/access/access.service.ts +++ b/apps/api/src/app/access/access.service.ts @@ -20,14 +20,14 @@ export class AccessService { } public async accesses(params: { + cursor?: Prisma.AccessWhereUniqueInput; include?: Prisma.AccessInclude; + orderBy?: Prisma.Enumerable; skip?: number; take?: number; - cursor?: Prisma.AccessWhereUniqueInput; where?: Prisma.AccessWhereInput; - orderBy?: Prisma.AccessOrderByWithRelationInput; }): Promise { - const { include, skip, take, cursor, where, orderBy } = params; + const { cursor, include, orderBy, skip, take, where } = params; return this.prismaService.access.findMany({ cursor, @@ -52,4 +52,17 @@ export class AccessService { where }); } + + public async updateAccess({ + data, + where + }: { + data: Prisma.AccessUpdateInput; + where: Prisma.AccessWhereUniqueInput; + }): Promise { + return this.prismaService.access.update({ + data, + where + }); + } } 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..2850186f9 --- /dev/null +++ b/apps/api/src/app/access/update-access.dto.ts @@ -0,0 +1,19 @@ +import { AccessPermission } from '@prisma/client'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UpdateAccessDto { + @IsOptional() + @IsString() + alias?: string; + + @IsOptional() + @IsUUID() + granteeUserId?: string; + + @IsString() + id: 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..e61ee0c9e 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,14 @@ + @if (user?.settings?.isExperimentalFeatures) { + + } @if (element.type === 'PUBLIC') { + } + @if ( + user?.settings?.isExperimentalFeatures || element.type === 'PUBLIC' + ) {
} 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 178df374d..bdb9af6ed 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 @@ -115,6 +115,8 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit { .subscribe((params) => { if (params['createDialog']) { this.openCreateAccessDialog(); + } else if (params['editDialog'] && params['accessId']) { + this.openUpdateAccessDialog(params['accessId']); } }); @@ -173,6 +175,12 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit { }); } + public onUpdateAccess(aId: string) { + this.router.navigate([], { + queryParams: { accessId: aId, editDialog: true } + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); @@ -200,6 +208,40 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit { }); } + private openUpdateAccessDialog(accessId: string) { + const access = this.accessesGive?.find(({ id }) => { + return id === accessId; + }); + + if (!access) { + console.log('Could not find access.'); + + return; + } + + const dialogRef = this.dialog.open(GfCreateOrUpdateAccessDialogComponent, { + data: { + access: { + alias: access.alias, + id: access.id, + grantee: access.grantee === 'Public' ? null : access.grantee, + permissions: access.permissions, + type: access.type + } + }, + height: this.deviceType === 'mobile' ? '98vh' : undefined, + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.update(); + } + + this.router.navigate(['.'], { relativeTo: this.route }); + }); + } + 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..8160c2c8e 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)" + (accessToUpdate)="onUpdateAccess($event)" /> @if (hasPermissionToCreateAccess) {
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 6ab370399..c2678924b 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'; @@ -792,6 +793,10 @@ export class DataService { return this.http.post('/api/v1/watchlist', watchlistItem); } + public putAccess(aAccess: UpdateAccessDto) { + return this.http.put(`/api/v1/access/${aAccess.id}`, aAccess); + } + public putAccount(aAccount: UpdateAccountDto) { return this.http.put(`/api/v1/account/${aAccount.id}`, aAccount); } diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 52794f7dc..51f327d32 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -49,6 +49,7 @@ export const permissions = { syncDemoUserAccount: 'syncDemoUserAccount', toggleReadOnlyMode: 'toggleReadOnlyMode', updateAccount: 'updateAccount', + updateAccess: 'updateAccess', updateAuthDevice: 'updateAuthDevice', updateMarketData: 'updateMarketData', updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', @@ -93,6 +94,7 @@ export function getPermissions(aRole: Role): string[] { permissions.readTags, permissions.readWatchlist, permissions.updateAccount, + permissions.updateAccess, permissions.updateAuthDevice, permissions.updateMarketData, permissions.updateMarketDataOfOwnAssetProfile, @@ -133,6 +135,7 @@ export function getPermissions(aRole: Role): string[] { permissions.readMarketDataOfOwnAssetProfile, permissions.readWatchlist, permissions.updateAccount, + permissions.updateAccess, permissions.updateAuthDevice, permissions.updateMarketDataOfOwnAssetProfile, permissions.updateOrder,