From 62f85293e2e2106ab847e81fb1c57f509e7e1df0 Mon Sep 17 00:00:00 2001
From: Francisco Silva <franciscojjsilva@gmail.com>
Date: Sat, 6 Jan 2024 10:27:21 +0100
Subject: [PATCH] #2820 Grant private access (#2822)

* Grant private access

* Update changelog
---
 CHANGELOG.md                                  |  1 +
 apps/api/src/app/access/access.controller.ts  | 33 +++++++++++----
 apps/api/src/app/access/access.module.ts      |  3 +-
 apps/api/src/app/access/create-access.dto.ts  |  4 +-
 .../access-table/access-table.component.html  |  2 +-
 ...reate-or-update-access-dialog.component.ts | 41 +++++++++++++++++--
 .../create-or-update-access-dialog.html       | 17 ++++++++
 .../user-account-access.component.ts          | 26 ++++--------
 .../user-account-settings.html                |  4 +-
 .../src/lib/interfaces/access.interface.ts    |  4 +-
 10 files changed, 99 insertions(+), 36 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ccfda04..631332ed 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
 - Added a hint for _Time-Weighted Rate of Return_ (TWR) to the portfolio summary tab on the home page
 - Added support for REST APIs (`JSON`) via the scraper configuration
 - Enabled the _Redis_ authentication in the `docker-compose` files
diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts
index 47f6c08b..b673bb73 100644
--- a/apps/api/src/app/access/access.controller.ts
+++ b/apps/api/src/app/access/access.controller.ts
@@ -1,5 +1,6 @@
 import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
 import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
+import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
 import { Access } from '@ghostfolio/common/interfaces';
 import { permissions } from '@ghostfolio/common/permissions';
 import type { RequestWithUser } from '@ghostfolio/common/types';
@@ -26,6 +27,7 @@ import { CreateAccessDto } from './create-access.dto';
 export class AccessController {
   public constructor(
     private readonly accessService: AccessService,
+    private readonly configurationService: ConfigurationService,
     @Inject(REQUEST) private readonly request: RequestWithUser
   ) {}
 
@@ -65,13 +67,30 @@ export class AccessController {
   public async createAccess(
     @Body() data: CreateAccessDto
   ): Promise<AccessModel> {
-    return this.accessService.createAccess({
-      alias: data.alias || undefined,
-      GranteeUser: data.granteeUserId
-        ? { connect: { id: data.granteeUserId } }
-        : undefined,
-      User: { connect: { id: this.request.user.id } }
-    });
+    if (
+      this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
+      this.request.user.subscription.type === 'Basic'
+    ) {
+      throw new HttpException(
+        getReasonPhrase(StatusCodes.FORBIDDEN),
+        StatusCodes.FORBIDDEN
+      );
+    }
+
+    try {
+      return await this.accessService.createAccess({
+        alias: data.alias || undefined,
+        GranteeUser: data.granteeUserId
+          ? { connect: { id: data.granteeUserId } }
+          : undefined,
+        User: { connect: { id: this.request.user.id } }
+      });
+    } catch {
+      throw new HttpException(
+        getReasonPhrase(StatusCodes.BAD_REQUEST),
+        StatusCodes.BAD_REQUEST
+      );
+    }
   }
 
   @Delete(':id')
diff --git a/apps/api/src/app/access/access.module.ts b/apps/api/src/app/access/access.module.ts
index b9813d17..7f466d35 100644
--- a/apps/api/src/app/access/access.module.ts
+++ b/apps/api/src/app/access/access.module.ts
@@ -1,3 +1,4 @@
+import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
 import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
 import { Module } from '@nestjs/common';
 
@@ -7,7 +8,7 @@ import { AccessService } from './access.service';
 @Module({
   controllers: [AccessController],
   exports: [AccessService],
-  imports: [PrismaModule],
+  imports: [ConfigurationModule, PrismaModule],
   providers: [AccessService]
 })
 export class AccessModule {}
diff --git a/apps/api/src/app/access/create-access.dto.ts b/apps/api/src/app/access/create-access.dto.ts
index b9cf8892..a6a24690 100644
--- a/apps/api/src/app/access/create-access.dto.ts
+++ b/apps/api/src/app/access/create-access.dto.ts
@@ -1,4 +1,4 @@
-import { IsOptional, IsString } from 'class-validator';
+import { IsOptional, IsString, IsUUID } from 'class-validator';
 
 export class CreateAccessDto {
   @IsOptional()
@@ -6,7 +6,7 @@ export class CreateAccessDto {
   alias?: string;
 
   @IsOptional()
-  @IsString()
+  @IsUUID()
   granteeUserId?: string;
 
   @IsOptional()
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 761bce9f..e150d16a 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
@@ -14,7 +14,7 @@
   </ng-container>
 
   <ng-container matColumnDef="type">
-    <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
+    <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
     <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
       <div class="align-items-center d-flex">
         <ion-icon class="mr-1" name="lock-closed-outline" />
diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
index 2aa38f4d..495ad335 100644
--- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
+++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
@@ -1,5 +1,6 @@
 import {
   ChangeDetectionStrategy,
+  ChangeDetectorRef,
   Component,
   Inject,
   OnDestroy
@@ -7,7 +8,9 @@ import {
 import { FormBuilder, FormGroup, Validators } from '@angular/forms';
 import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
 import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
-import { Subject } from 'rxjs';
+import { DataService } from '@ghostfolio/client/services/data.service';
+import { StatusCodes } from 'http-status-codes';
+import { EMPTY, Subject, catchError, takeUntil } from 'rxjs';
 
 import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
 
@@ -24,15 +27,32 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
   private unsubscribeSubject = new Subject<void>();
 
   public constructor(
+    private changeDetectorRef: ChangeDetectorRef,
     @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
     public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
+    private dataService: DataService,
     private formBuilder: FormBuilder
   ) {}
 
   ngOnInit() {
     this.accessForm = this.formBuilder.group({
       alias: [this.data.access.alias],
-      type: [this.data.access.type, Validators.required]
+      type: [this.data.access.type, Validators.required],
+      userId: [this.data.access.grantee, Validators.required]
+    });
+
+    this.accessForm.get('type').valueChanges.subscribe((value) => {
+      const userIdControl = this.accessForm.get('userId');
+
+      if (value === 'PRIVATE') {
+        userIdControl.setValidators(Validators.required);
+      } else {
+        userIdControl.clearValidators();
+      }
+
+      userIdControl.updateValueAndValidity();
+
+      this.changeDetectorRef.markForCheck();
     });
   }
 
@@ -43,10 +63,25 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
   public onSubmit() {
     const access: CreateAccessDto = {
       alias: this.accessForm.controls['alias'].value,
+      granteeUserId: this.accessForm.controls['userId'].value,
       type: this.accessForm.controls['type'].value
     };
 
-    this.dialogRef.close({ access });
+    this.dataService
+      .postAccess(access)
+      .pipe(
+        catchError((error) => {
+          if (error.status === StatusCodes.BAD_REQUEST) {
+            alert($localize`Oops! Could not grant access.`);
+          }
+
+          return EMPTY;
+        }),
+        takeUntil(this.unsubscribeSubject)
+      )
+      .subscribe(() => {
+        this.dialogRef.close({ access });
+      });
   }
 
   public ngOnDestroy() {
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 c2afc51f..31b9d762 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
@@ -21,10 +21,27 @@
       <mat-form-field appearance="outline" class="w-100">
         <mat-label i18n>Type</mat-label>
         <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>
+
+    @if (accessForm.controls['type'].value === 'PRIVATE') {
+    <div>
+      <mat-form-field appearance="outline" class="w-100">
+        <mat-label
+          >Ghostfolio <ng-container i18n>User ID</ng-container></mat-label
+        >
+        <input
+          formControlName="userId"
+          matInput
+          type="text"
+          (keydown.enter)="$event.stopPropagation()"
+        />
+      </mat-form-field>
+    </div>
+    }
   </div>
   <div class="justify-content-end" mat-dialog-actions>
     <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
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 1bd1d85d..99956383 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
@@ -105,32 +105,20 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
       data: {
         access: {
           alias: '',
-          type: 'PUBLIC'
+          type: 'PRIVATE'
         }
       },
       height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
       width: this.deviceType === 'mobile' ? '100vw' : '50rem'
     });
 
-    dialogRef
-      .afterClosed()
-      .pipe(takeUntil(this.unsubscribeSubject))
-      .subscribe((data: any) => {
-        const access: CreateAccessDto = data?.access;
+    dialogRef.afterClosed().subscribe((access) => {
+      if (access) {
+        this.update();
+      }
 
-        if (access) {
-          this.dataService
-            .postAccess({ alias: access.alias })
-            .pipe(takeUntil(this.unsubscribeSubject))
-            .subscribe({
-              next: () => {
-                this.update();
-              }
-            });
-        }
-
-        this.router.navigate(['.'], { relativeTo: this.route });
-      });
+      this.router.navigate(['.'], { relativeTo: this.route });
+    });
   }
 
   private update() {
diff --git a/apps/client/src/app/components/user-account-settings/user-account-settings.html b/apps/client/src/app/components/user-account-settings/user-account-settings.html
index b77b5e94..0a9462a5 100644
--- a/apps/client/src/app/components/user-account-settings/user-account-settings.html
+++ b/apps/client/src/app/components/user-account-settings/user-account-settings.html
@@ -201,7 +201,9 @@
         </div>
       </div>
       <div class="align-items-center d-flex mt-4 py-1">
-        <div class="pr-1 w-50" i18n>User ID</div>
+        <div class="pr-1 w-50">
+          Ghostfolio <ng-container i18n>User ID</ng-container>
+        </div>
         <div class="pl-1 text-monospace w-50">{{ user?.id }}</div>
       </div>
       <div class="align-items-center d-flex py-1">
diff --git a/libs/common/src/lib/interfaces/access.interface.ts b/libs/common/src/lib/interfaces/access.interface.ts
index 27503c87..299616cf 100644
--- a/libs/common/src/lib/interfaces/access.interface.ts
+++ b/libs/common/src/lib/interfaces/access.interface.ts
@@ -1,6 +1,6 @@
 export interface Access {
   alias?: string;
-  grantee: string;
+  grantee?: string;
   id: string;
-  type: 'PUBLIC' | 'RESTRICTED_VIEW';
+  type: 'PRIVATE' | 'PUBLIC' | 'RESTRICTED_VIEW';
 }