From 3df8810412c09adbc176ef24e4bcd250028d0578 Mon Sep 17 00:00:00 2001 From: Francisco Silva Date: Sat, 27 Jan 2024 09:44:13 +0100 Subject: [PATCH] Feature/Add support to grant private access with permissions (#2870) * Add support to grant private access with permissions * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 1 + apps/api/src/app/access/access.controller.ts | 33 ++++++++++-------- apps/api/src/app/access/create-access.dto.ts | 7 ++-- .../src/app/portfolio/portfolio.controller.ts | 34 ++++++++++++++++--- apps/api/src/app/user/user.service.ts | 21 ++++++++++++ .../redact-values-in-response.interceptor.ts | 18 +++++++--- .../trackinsight/trackinsight.service.ts | 12 +++---- .../access-table/access-table.component.html | 9 +++-- .../home-overview/home-overview.component.ts | 1 - .../portfolio-performance.component.html | 8 ++--- .../portfolio-performance.component.ts | 4 ++- .../portfolio-summary.component.html | 16 ++++++--- ...reate-or-update-access-dialog.component.ts | 10 ++++-- .../create-or-update-access-dialog.html | 17 ++++++++-- .../interfaces/interfaces.ts | 3 +- .../user-account-access.component.ts | 5 +-- .../src/lib/interfaces/access.interface.ts | 6 +++- libs/common/src/lib/types/access-type.type.ts | 1 + libs/common/src/lib/types/index.ts | 2 ++ .../src/lib/types/user-with-settings.type.ts | 3 +- libs/ui/src/lib/value/value.component.html | 2 +- 21 files changed, 155 insertions(+), 58 deletions(-) create mode 100644 libs/common/src/lib/types/access-type.type.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9334d7407..fd84af247 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 grant private access with permissions (experimental) - Added `permissions` to the `Access` model ### Changed diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts index b673bb734..9aca159d8 100644 --- a/apps/api/src/app/access/access.controller.ts +++ b/apps/api/src/app/access/access.controller.ts @@ -42,23 +42,27 @@ export class AccessController { where: { userId: this.request.user.id } }); - return accessesWithGranteeUser.map((access) => { - if (access.GranteeUser) { + return accessesWithGranteeUser.map( + ({ alias, GranteeUser, id, permissions }) => { + if (GranteeUser) { + return { + alias, + id, + permissions, + grantee: GranteeUser?.id, + type: 'PRIVATE' + }; + } + return { - alias: access.alias, - grantee: access.GranteeUser?.id, - id: access.id, - type: 'RESTRICTED_VIEW' + alias, + id, + permissions, + grantee: 'Public', + type: 'PUBLIC' }; } - - return { - alias: access.alias, - grantee: 'Public', - id: access.id, - type: 'PUBLIC' - }; - }); + ); } @HasPermission(permissions.createAccess) @@ -83,6 +87,7 @@ export class AccessController { GranteeUser: data.granteeUserId ? { connect: { id: data.granteeUserId } } : undefined, + permissions: data.permissions, User: { connect: { id: this.request.user.id } } }); } catch { diff --git a/apps/api/src/app/access/create-access.dto.ts b/apps/api/src/app/access/create-access.dto.ts index a6a24690d..087df7183 100644 --- a/apps/api/src/app/access/create-access.dto.ts +++ b/apps/api/src/app/access/create-access.dto.ts @@ -1,4 +1,5 @@ -import { IsOptional, IsString, IsUUID } from 'class-validator'; +import { AccessPermission } from '@prisma/client'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; export class CreateAccessDto { @IsOptional() @@ -9,7 +10,7 @@ export class CreateAccessDto { @IsUUID() granteeUserId?: string; + @IsEnum(AccessPermission, { each: true }) @IsOptional() - @IsString() - type?: 'PUBLIC'; + permissions?: AccessPermission[]; } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index d137ae97c..b9932fb4f 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -74,6 +74,11 @@ export class PortfolioController { ): Promise { let hasDetails = true; let hasError = false; + const hasReadRestrictedAccessPermission = + this.userService.hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }); if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { hasDetails = this.request.user.subscription.type === 'Premium'; @@ -108,7 +113,7 @@ export class PortfolioController { let portfolioSummary = summary; if ( - impersonationId || + hasReadRestrictedAccessPermission || this.userService.isRestrictedView(this.request.user) ) { const totalInvestment = Object.values(holdings) @@ -148,7 +153,7 @@ export class PortfolioController { if ( hasDetails === false || - impersonationId || + hasReadRestrictedAccessPermission || this.userService.isRestrictedView(this.request.user) ) { portfolioSummary = nullifyValuesInObject(summary, [ @@ -164,6 +169,7 @@ export class PortfolioController { 'excludedAccountsAndActivities', 'fees', 'fireWealth', + 'interest', 'items', 'liabilities', 'netWorth', @@ -216,6 +222,12 @@ export class PortfolioController { @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string ): Promise { + const hasReadRestrictedAccessPermission = + this.userService.hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }); + const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -230,7 +242,7 @@ export class PortfolioController { }); if ( - impersonationId || + hasReadRestrictedAccessPermission || this.userService.isRestrictedView(this.request.user) ) { const maxDividend = dividends.reduce( @@ -266,6 +278,12 @@ export class PortfolioController { @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string ): Promise { + const hasReadRestrictedAccessPermission = + this.userService.hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }); + const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -281,7 +299,7 @@ export class PortfolioController { }); if ( - impersonationId || + hasReadRestrictedAccessPermission || this.userService.isRestrictedView(this.request.user) ) { const maxInvestment = investments.reduce( @@ -329,6 +347,12 @@ export class PortfolioController { @Query('tags') filterByTags?: string, @Query('withExcludedAccounts') withExcludedAccounts = false ): Promise { + const hasReadRestrictedAccessPermission = + this.userService.hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }); + const filters = this.apiService.buildFiltersFromQueryParams({ filterByAccounts, filterByAssetClasses, @@ -344,7 +368,7 @@ export class PortfolioController { }); if ( - impersonationId || + hasReadRestrictedAccessPermission || this.request.user.Settings.settings.viewMode === 'ZEN' || this.userService.isRestrictedView(this.request.user) ) { diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index fe6625439..a4812c136 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -105,6 +105,24 @@ export class UserService { return usersWithAdminRole.length > 0; } + public hasReadRestrictedAccessPermission({ + impersonationId, + user + }: { + impersonationId: string; + user: UserWithSettings; + }) { + if (!impersonationId) { + return false; + } + + const access = user.Access?.find(({ id }) => { + return id === impersonationId; + }); + + return access?.permissions?.includes('READ_RESTRICTED') ?? true; + } + public isRestrictedView(aUser: UserWithSettings) { return aUser.Settings.settings.isRestrictedView ?? false; } @@ -113,6 +131,7 @@ export class UserService { userWhereUniqueInput: Prisma.UserWhereUniqueInput ): Promise { const { + Access, accessToken, Account, Analytics, @@ -127,6 +146,7 @@ export class UserService { updatedAt } = await this.prismaService.user.findUnique({ include: { + Access: true, Account: { include: { Platform: true } }, @@ -138,6 +158,7 @@ export class UserService { }); const user: UserWithSettings = { + Access, accessToken, Account, authChallenge, diff --git a/apps/api/src/interceptors/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response.interceptor.ts index 6b10a4ebb..aca3bd5e4 100644 --- a/apps/api/src/interceptors/redact-values-in-response.interceptor.ts +++ b/apps/api/src/interceptors/redact-values-in-response.interceptor.ts @@ -1,6 +1,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { redactAttributes } from '@ghostfolio/api/helper/object.helper'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { UserWithSettings } from '@ghostfolio/common/types'; import { CallHandler, ExecutionContext, @@ -22,13 +23,20 @@ export class RedactValuesInResponseInterceptor ): Observable { return next.handle().pipe( map((data: any) => { - const request = context.switchToHttp().getRequest(); - const hasImpersonationId = - !!request.headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]; + const { headers, user }: { headers: Headers; user: UserWithSettings } = + context.switchToHttp().getRequest(); + + const impersonationId = + headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]; + const hasReadRestrictedPermission = + this.userService.hasReadRestrictedAccessPermission({ + impersonationId, + user + }); if ( - hasImpersonationId || - this.userService.isRestrictedView(request.user) + hasReadRestrictedPermission || + this.userService.isRestrictedView(user) ) { data = redactAttributes({ object: data, diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 9a79fa694..6d8995386 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -62,9 +62,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { }, this.configurationService.get('REQUEST_TIMEOUT')); return got( - `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split( - '.' - )?.[0]}.json`, + `${TrackinsightDataEnhancerService.baseUrl}/funds/${ + symbol.split('.')?.[0] + }.json`, { // @ts-ignore signal: abortController.signal @@ -104,9 +104,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { }, this.configurationService.get('REQUEST_TIMEOUT')); return got( - `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split( - '.' - )?.[0]}.json`, + `${TrackinsightDataEnhancerService.baseUrl}/holdings/${ + symbol.split('.')?.[0] + }.json`, { // @ts-ignore signal: abortController.signal 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 473be0e81..8112ca4ad 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 @@ -17,8 +17,13 @@ Permission
- - Restricted View + @if (element.permissions.includes('READ')) { + + View + } @else if (element.permissions.includes('READ_RESTRICTED')) { + + Restricted view + }
diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts index 74bdec311..47809ee53 100644 --- a/apps/client/src/app/components/home-overview/home-overview.component.ts +++ b/apps/client/src/app/components/home-overview/home-overview.component.ts @@ -74,7 +74,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit { }); this.showDetails = - !this.hasImpersonationId && !this.user.settings.isRestrictedView && this.user.settings.viewMode !== 'ZEN'; diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 652d30b63..77c643c3a 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -1,14 +1,12 @@
-
+
@if (errors?.length > 0 && !isLoading) { }
diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts index fd304cad0..f8e96471c 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.ts @@ -58,7 +58,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit { duration: 1, separator: getNumberFormatGroup(this.locale) }).start(); - } else if (this.performance?.currentValue === null) { + } else if (this.showDetails === false) { new CountUp( 'value', this.performance?.currentNetPerformancePercent * 100, @@ -69,6 +69,8 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit { separator: getNumberFormatGroup(this.locale) } ).start(); + } else { + this.value.nativeElement.innerHTML = '*****'; } } } diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 9b6232d29..2ba1c4216 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -10,8 +10,8 @@ [hidden]="summary?.ordersCount === null" >
- {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1 {transaction} - other {transactions}} + {{ summary?.ordersCount }} + {summary?.ordersCount, plural, =1 {transaction} other {transactions}}
@@ -71,7 +71,11 @@
Gross Performance - (TWR) + (TWR)
Net Performance - (TWR) + (TWR)
{ + this.accessForm.get('type').valueChanges.subscribe((accessType) => { + const permissionsControl = this.accessForm.get('permissions'); const userIdControl = this.accessForm.get('userId'); - if (value === 'PRIVATE') { + if (accessType === 'PRIVATE') { + permissionsControl.setValidators(Validators.required); userIdControl.setValidators(Validators.required); } else { userIdControl.clearValidators(); } + permissionsControl.updateValueAndValidity(); userIdControl.updateValueAndValidity(); this.changeDetectorRef.markForCheck(); @@ -64,7 +68,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy { const access: CreateAccessDto = { alias: this.accessForm.controls['alias'].value, granteeUserId: this.accessForm.controls['userId'].value, - type: this.accessForm.controls['type'].value + permissions: [this.accessForm.controls['permissions'].value] }; this.dataService diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html index 31b9d7626..863ac5e16 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html @@ -30,9 +30,20 @@ @if (accessForm.controls['type'].value === 'PRIVATE') {
- Ghostfolio User ID + Permission + + Restricted view + @if(data?.user?.settings?.isExperimentalFeatures) { + View + } + + +
+
+ + + Ghostfolio User ID + - *** + ***** {{ formattedValue }}