Feature/add user interface for granting and revoking public access (#439)

* Add user interface for granting and revoking public access

* Update changelog
pull/440/head
Thomas Kaul 3 years ago committed by GitHub
parent 1296f95602
commit 2de0e75cb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added the user interface for granting and revoking public access to share the portfolio
### Changed
- Moved the data enhancer calls from the data provider (`get()`) to the data gathering service to reduce traffic to 3rd party data providers
@ -26,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added a public page to share your portfolio
- Added a public page to share the portfolio
### Changed

@ -1,10 +1,29 @@
import { Access } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
Param,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessModule } from './access.module';
import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
@Controller('access')
export class AccessController {
@ -39,4 +58,49 @@ export class AccessController {
};
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createAccess(
@Body() data: CreateAccessDto
): Promise<AccessModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createAccess
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accessService.createAccess({
User: { connect: { id: this.request.user.id } }
});
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAccess
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accessService.deleteAccess({
id_userId: {
id,
userId: this.request.user.id
}
});
}
}

@ -1,7 +1,7 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Access, Prisma } from '@prisma/client';
@Injectable()
export class AccessService {
@ -37,4 +37,18 @@ export class AccessService {
where
});
}
public async createAccess(data: Prisma.AccessCreateInput): Promise<Access> {
return this.prismaService.access.create({
data
});
}
public async deleteAccess(
where: Prisma.AccessWhereUniqueInput
): Promise<Access> {
return this.prismaService.access.delete({
where
});
}
}

@ -0,0 +1 @@
export class CreateAccessDto {}

@ -272,7 +272,7 @@ export class PortfolioController {
return <any>res.json({ accounts: {}, holdings: {} });
}
const { hasErrors, holdings } = await this.portfolioService.getDetails(
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId
);
@ -281,10 +281,6 @@ export class PortfolioController {
holdings: {}
};
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
res.status(StatusCodes.ACCEPTED);
}
const totalValue = Object.values(holdings)
.filter((holding) => {
return holding.assetClass === 'EQUITY';

@ -1,24 +1,52 @@
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
<ng-container matColumnDef="granteeAlias">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>User</th>
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Grantee</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.granteeAlias }}
</td></ng-container
>
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<ng-container *ngIf="element.type === 'PUBLIC'">
<ion-icon class="mr-1" name="link-outline"></ion-icon>
{{ baseUrl }}/p/{{ element.id }}
</ng-container>
<ng-container *ngIf="element.type === 'RESTRICTED_VIEW'">
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container>
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
Restricted View
</ng-container>
</td></ng-container
>
</td>
</ng-container>
<ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<ng-container *ngIf="element.type === 'PUBLIC'">
<ion-icon class="mr-1" name="link-outline"></ion-icon>
<a href="{{ baseUrl }}/p/{{ element.id }}" target="_blank"
>{{ baseUrl }}/p/{{ element.id }}</a
>
</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="transactionMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #transactionMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onDeleteAccess(element.id)">
Revoke
</button>
</mat-menu>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>

@ -2,4 +2,12 @@
:host {
display: block;
a {
color: rgba(var(--palette-primary-500), 1);
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
}

@ -1,9 +1,11 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnInit
OnInit,
Output
} from '@angular/core';
import { MatTableDataSource } from '@angular/material/table';
import { Access } from '@ghostfolio/common/interfaces';
@ -16,18 +18,37 @@ import { Access } from '@ghostfolio/common/interfaces';
})
export class AccessTableComponent implements OnChanges, OnInit {
@Input() accesses: Access[];
@Input() showActions: boolean;
@Output() accessDeleted = new EventEmitter<string>();
public baseUrl = window.location.origin;
public dataSource: MatTableDataSource<Access>;
public displayedColumns = ['granteeAlias', 'type'];
public displayedColumns = [];
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['granteeAlias', 'type', 'details'];
if (this.showActions) {
this.displayedColumns.push('actions');
}
if (this.accesses) {
this.dataSource = new MatTableDataSource(this.accesses);
}
}
public onDeleteAccess(aId: string) {
const confirmation = confirm(
'Do you really want to revoke this granted access?'
);
if (confirmation) {
this.accessDeleted.emit(aId);
}
}
}

@ -1,5 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table';
import { AccessTableComponent } from './access-table.component';
@ -7,7 +9,7 @@ import { AccessTableComponent } from './access-table.component';
@NgModule({
declarations: [AccessTableComponent],
exports: [AccessTableComponent],
imports: [CommonModule, MatTableModule],
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

@ -5,20 +5,26 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
MatSlideToggle,
MatSlideToggleChange
} from '@angular/material/slide-toggle';
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DeviceDetectorService } from 'ngx-device-detector';
import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
@Component({
host: { class: 'mb-5' },
selector: 'gf-account-page',
@ -35,7 +41,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public couponId: string;
public currencies: string[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public deviceType: string;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateAccess: boolean;
public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public price: number;
@ -50,6 +59,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
private router: Router,
private stripeService: StripeService,
private userService: UserService,
public webAuthnService: WebAuthnService
@ -65,6 +78,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
permissions.enableSubscription
);
this.hasPermissionToDeleteAccess = hasPermission(
globalPermissions,
permissions.deleteAccess
);
this.price = subscriptions?.[0]?.price;
this.priceId = subscriptions?.[0]?.priceId;
@ -74,6 +92,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToCreateAccess = hasPermission(
this.user.permissions,
permissions.createAccess
);
this.hasPermissionToDeleteAccess = hasPermission(
this.user.permissions,
permissions.deleteAccess
);
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
@ -87,12 +115,22 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck();
}
});
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog']) {
this.openCreateAccessDialog();
}
});
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.update();
}
@ -136,6 +174,17 @@ export class AccountPageComponent implements OnDestroy, OnInit {
});
}
public onDeleteAccess(aId: string) {
this.dataService
.deleteAccess(aId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.update();
}
});
}
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isRestrictedView: aEvent.checked })
@ -175,6 +224,38 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openCreateAccessDialog(): void {
const dialogRef = this.dialog.open(CreateOrUpdateAccessDialog, {
data: {
access: {
type: 'PUBLIC'
}
},
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({})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe({
next: () => {
this.update();
}
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private deregisterDevice() {
this.webAuthnService
.deregister()

@ -132,10 +132,26 @@
</mat-card>
</div>
</div>
<div *ngIf="accesses?.length > 0" class="row">
<div class="row">
<div class="col">
<h3 class="mb-3 text-center" i18n>Granted Access</h3>
<gf-access-table [accesses]="accesses"></gf-access-table>
<gf-access-table
[accesses]="accesses"
[showActions]="hasPermissionToDeleteAccess"
(accessDeleted)="onDeleteAccess($event)"
></gf-access-table>
</div>
</div>
<div *ngIf="hasPermissionToCreateAccess" class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[routerLink]="[]"
[queryParams]="{ createDialog: true }"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

@ -12,6 +12,7 @@ import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/acce
import { AccountPageRoutingModule } from './account-page-routing.module';
import { AccountPageComponent } from './account-page.component';
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
@NgModule({
declarations: [AccountPageComponent],
@ -20,6 +21,7 @@ import { AccountPageComponent } from './account-page.component';
AccountPageRoutingModule,
CommonModule,
FormsModule,
GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule,
MatButtonModule,
MatCardModule,

@ -2,6 +2,26 @@
color: rgb(var(--dark-primary-text));
display: block;
gf-access-table {
overflow-x: auto;
table {
min-width: 100%;
.mat-row,
.mat-header-row {
width: 100%;
}
}
}
.fab-container {
position: fixed;
right: 2rem;
bottom: 2rem;
z-index: 999;
}
.hint-text {
font-size: 90%;
line-height: 1.2;

@ -0,0 +1,37 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Subject } from 'rxjs';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-or-update-access-dialog',
styleUrls: ['./create-or-update-access-dialog.scss'],
templateUrl: 'create-or-update-access-dialog.html'
})
export class CreateOrUpdateAccessDialog implements OnDestroy {
private unsubscribeSubject = new Subject<void>();
public constructor(
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams
) {}
ngOnInit() {}
public onCancel(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

@ -0,0 +1,25 @@
<form #addAccessForm="ngForm" class="d-flex flex-column h-100">
<h1 i18n mat-dialog-title>Grant access</h1>
<div class="flex-grow-1" mat-dialog-content>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.access.type">
<mat-option i18n value="PUBLIC">Public</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="justify-content-end" mat-dialog-actions>
<button i18n mat-button (click)="onCancel()">Cancel</button>
<button
color="primary"
i18n
mat-flat-button
[disabled]="!addAccessForm.form.valid"
[mat-dialog-close]="data"
>
Save
</button>
</div>
</form>

@ -0,0 +1,25 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSelectModule } from '@angular/material/select';
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog.component';
@NgModule({
declarations: [CreateOrUpdateAccessDialog],
exports: [],
imports: [
CommonModule,
FormsModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatSelectModule,
ReactiveFormsModule
],
providers: []
})
export class GfCreateOrUpdateAccessDialogModule {}

@ -0,0 +1,7 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
}
}

@ -0,0 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams {
access: Access;
}

@ -45,7 +45,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
private router: Router,
private userService: UserService
) {
this.routeQueryParams = route.queryParams
this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['createDialog']) {

@ -12,8 +12,8 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Type</mat-label>
<mat-select name="type" required [(value)]="data.account.accountType">
<mat-option value="CASH" i18n>Cash</mat-option>
<mat-option value="SECURITIES" i18n>Securities</mat-option>
<mat-option i18n value="CASH">Cash</mat-option>
<mat-option i18n value="SECURITIES">Securities</mat-option>
</mat-select>
</mat-form-field>
</div>

@ -1,7 +1,9 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Portfolio</h3>
<h3 class="h4 mb-3 text-center" i18n>
Hello, someone has shared a <strong>Portfolio</strong> with you!
</h3>
</div>
</div>
<div class="proportion-charts row">

@ -1,8 +1,8 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import {
@ -69,6 +69,10 @@ export class DataService {
return this.http.get<AdminData>('/api/admin');
}
public deleteAccess(aId: string) {
return this.http.delete<any>(`/api/access/${aId}`);
}
public deleteAccount(aId: string) {
return this.http.delete<any>(`/api/account/${aId}`);
}
@ -197,6 +201,10 @@ export class DataService {
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`);
}
public postAccess(aAccess: CreateAccessDto) {
return this.http.post<OrderModel>(`/api/access`, aAccess);
}
public postAccount(aAccount: CreateAccountDto) {
return this.http.post<OrderModel>(`/api/account`, aAccount);
}

@ -7,9 +7,11 @@ export function isApiTokenAuthorized(aApiToken: string) {
export const permissions = {
accessAdminControl: 'accessAdminControl',
accessFearAndGreedIndex: 'accessFearAndGreedIndex',
createAccess: 'createAccess',
createAccount: 'createAccount',
createOrder: 'createOrder',
createUserAccount: 'createUserAccount',
deleteAccess: 'deleteAccess',
deleteAccount: 'deleteAcccount',
deleteAuthDevice: 'deleteAuthDevice',
deleteOrder: 'deleteOrder',
@ -38,8 +40,10 @@ export function getPermissions(aRole: Role): string[] {
case 'ADMIN':
return [
permissions.accessAdminControl,
permissions.createAccess,
permissions.createAccount,
permissions.createOrder,
permissions.deleteAccess,
permissions.deleteAccount,
permissions.deleteAuthDevice,
permissions.deleteOrder,
@ -56,8 +60,10 @@ export function getPermissions(aRole: Role): string[] {
case 'USER':
return [
permissions.createAccess,
permissions.createAccount,
permissions.createOrder,
permissions.deleteAccess,
permissions.deleteAccount,
permissions.deleteAuthDevice,
permissions.deleteOrder,

Loading…
Cancel
Save