#2820 Grant private access (#2822)

* Grant private access

* Update changelog
pull/2831/head
Francisco Silva 1 year ago committed by GitHub
parent 6a048cee85
commit 62f85293e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### 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 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 - Added support for REST APIs (`JSON`) via the scraper configuration
- Enabled the _Redis_ authentication in the `docker-compose` files - Enabled the _Redis_ authentication in the `docker-compose` files

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { Access } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
@ -26,6 +27,7 @@ import { CreateAccessDto } from './create-access.dto';
export class AccessController { export class AccessController {
public constructor( public constructor(
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -65,13 +67,30 @@ export class AccessController {
public async createAccess( public async createAccess(
@Body() data: CreateAccessDto @Body() data: CreateAccessDto
): Promise<AccessModel> { ): Promise<AccessModel> {
return this.accessService.createAccess({ if (
alias: data.alias || undefined, this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
GranteeUser: data.granteeUserId this.request.user.subscription.type === 'Basic'
? { connect: { id: data.granteeUserId } } ) {
: undefined, throw new HttpException(
User: { connect: { id: this.request.user.id } } 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') @Delete(':id')

@ -1,3 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { AccessService } from './access.service';
@Module({ @Module({
controllers: [AccessController], controllers: [AccessController],
exports: [AccessService], exports: [AccessService],
imports: [PrismaModule], imports: [ConfigurationModule, PrismaModule],
providers: [AccessService] providers: [AccessService]
}) })
export class AccessModule {} export class AccessModule {}

@ -1,4 +1,4 @@
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString, IsUUID } from 'class-validator';
export class CreateAccessDto { export class CreateAccessDto {
@IsOptional() @IsOptional()
@ -6,7 +6,7 @@ export class CreateAccessDto {
alias?: string; alias?: string;
@IsOptional() @IsOptional()
@IsString() @IsUUID()
granteeUserId?: string; granteeUserId?: string;
@IsOptional() @IsOptional()

@ -14,7 +14,7 @@
</ng-container> </ng-container>
<ng-container matColumnDef="type"> <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> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<div class="align-items-center d-flex"> <div class="align-items-center d-flex">
<ion-icon class="mr-1" name="lock-closed-outline" /> <ion-icon class="mr-1" name="lock-closed-outline" />

@ -1,5 +1,6 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
ChangeDetectorRef,
Component, Component,
Inject, Inject,
OnDestroy OnDestroy
@ -7,7 +8,9 @@ import {
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; 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'; import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@ -24,15 +27,32 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams, @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>, public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
private dataService: DataService,
private formBuilder: FormBuilder private formBuilder: FormBuilder
) {} ) {}
ngOnInit() { ngOnInit() {
this.accessForm = this.formBuilder.group({ this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias], 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() { public onSubmit() {
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.controls['alias'].value, alias: this.accessForm.controls['alias'].value,
granteeUserId: this.accessForm.controls['userId'].value,
type: this.accessForm.controls['type'].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() { public ngOnDestroy() {

@ -21,10 +21,27 @@
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label> <mat-label i18n>Type</mat-label>
<mat-select formControlName="type"> <mat-select formControlName="type">
<mat-option i18n value="PRIVATE">Private</mat-option>
<mat-option i18n value="PUBLIC">Public</mat-option> <mat-option i18n value="PUBLIC">Public</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </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>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>

@ -105,32 +105,20 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
data: { data: {
access: { access: {
alias: '', alias: '',
type: 'PUBLIC' type: 'PRIVATE'
} }
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}); });
dialogRef dialogRef.afterClosed().subscribe((access) => {
.afterClosed() if (access) {
.pipe(takeUntil(this.unsubscribeSubject)) this.update();
.subscribe((data: any) => { }
const access: CreateAccessDto = data?.access;
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() { private update() {

@ -201,7 +201,9 @@
</div> </div>
</div> </div>
<div class="align-items-center d-flex mt-4 py-1"> <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 class="pl-1 text-monospace w-50">{{ user?.id }}</div>
</div> </div>
<div class="align-items-center d-flex py-1"> <div class="align-items-center d-flex py-1">

@ -1,6 +1,6 @@
export interface Access { export interface Access {
alias?: string; alias?: string;
grantee: string; grantee?: string;
id: string; id: string;
type: 'PUBLIC' | 'RESTRICTED_VIEW'; type: 'PRIVATE' | 'PUBLIC' | 'RESTRICTED_VIEW';
} }

Loading…
Cancel
Save