#2820 Grant private access (#2822)

* Grant private access

* Update changelog
pull/2831/head
Francisco Silva 5 months 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 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

@ -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')

@ -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 {}

@ -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()

@ -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" />

@ -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() {

@ -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>

@ -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;
if (access) {
this.dataService
.postAccess({ alias: access.alias })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.update();
}
});
}
dialogRef.afterClosed().subscribe((access) => {
if (access) {
this.update();
}
this.router.navigate(['.'], { relativeTo: this.route });
});
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private update() {

@ -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">

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

Loading…
Cancel
Save