Feature/add user interface for granting and revoking public access (#439)
* Add user interface for granting and revoking public access * Update changelog
This commit is contained in:
parent
1296f95602
commit
2de0e75cb8
@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the user interface for granting and revoking public access to share the portfolio
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Moved the data enhancer calls from the data provider (`get()`) to the data gathering service to reduce traffic to 3rd party data providers
|
- 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
|
||||||
|
|
||||||
- Added a public page to share your portfolio
|
- Added a public page to share the portfolio
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
@ -1,10 +1,29 @@
|
|||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
getPermissions,
|
||||||
|
hasPermission,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
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 { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
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 { AccessService } from './access.service';
|
||||||
|
import { CreateAccessDto } from './create-access.dto';
|
||||||
|
|
||||||
@Controller('access')
|
@Controller('access')
|
||||||
export class AccessController {
|
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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Access, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessService {
|
export class AccessService {
|
||||||
@ -37,4 +37,18 @@ export class AccessService {
|
|||||||
where
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
1
apps/api/src/app/access/create-access.dto.ts
Normal file
1
apps/api/src/app/access/create-access.dto.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class CreateAccessDto {}
|
@ -272,7 +272,7 @@ export class PortfolioController {
|
|||||||
return <any>res.json({ accounts: {}, holdings: {} });
|
return <any>res.json({ accounts: {}, holdings: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hasErrors, holdings } = await this.portfolioService.getDetails(
|
const { holdings } = await this.portfolioService.getDetails(
|
||||||
access.userId,
|
access.userId,
|
||||||
access.userId
|
access.userId
|
||||||
);
|
);
|
||||||
@ -281,10 +281,6 @@ export class PortfolioController {
|
|||||||
holdings: {}
|
holdings: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalValue = Object.values(holdings)
|
const totalValue = Object.values(holdings)
|
||||||
.filter((holding) => {
|
.filter((holding) => {
|
||||||
return holding.assetClass === 'EQUITY';
|
return holding.assetClass === 'EQUITY';
|
||||||
|
@ -1,24 +1,52 @@
|
|||||||
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<ng-container matColumnDef="granteeAlias">
|
<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>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
{{ element.granteeAlias }}
|
{{ element.granteeAlias }}
|
||||||
</td></ng-container
|
</td>
|
||||||
>
|
</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>Type</th>
|
||||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
|
||||||
<ng-container *ngIf="element.type === 'PUBLIC'">
|
<ng-container>
|
||||||
<ion-icon class="mr-1" name="link-outline"></ion-icon>
|
|
||||||
{{ baseUrl }}/p/{{ element.id }}
|
|
||||||
</ng-container>
|
|
||||||
<ng-container *ngIf="element.type === 'RESTRICTED_VIEW'">
|
|
||||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||||
Restricted View
|
Restricted View
|
||||||
</ng-container>
|
</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-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||||
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||||
|
@ -2,4 +2,12 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(var(--palette-primary-300), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
OnInit
|
OnInit,
|
||||||
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { Access } from '@ghostfolio/common/interfaces';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
@ -16,18 +18,37 @@ import { Access } from '@ghostfolio/common/interfaces';
|
|||||||
})
|
})
|
||||||
export class AccessTableComponent implements OnChanges, OnInit {
|
export class AccessTableComponent implements OnChanges, OnInit {
|
||||||
@Input() accesses: Access[];
|
@Input() accesses: Access[];
|
||||||
|
@Input() showActions: boolean;
|
||||||
|
|
||||||
|
@Output() accessDeleted = new EventEmitter<string>();
|
||||||
|
|
||||||
public baseUrl = window.location.origin;
|
public baseUrl = window.location.origin;
|
||||||
public dataSource: MatTableDataSource<Access>;
|
public dataSource: MatTableDataSource<Access>;
|
||||||
public displayedColumns = ['granteeAlias', 'type'];
|
public displayedColumns = [];
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
|
this.displayedColumns = ['granteeAlias', 'type', 'details'];
|
||||||
|
|
||||||
|
if (this.showActions) {
|
||||||
|
this.displayedColumns.push('actions');
|
||||||
|
}
|
||||||
|
|
||||||
if (this.accesses) {
|
if (this.accesses) {
|
||||||
this.dataSource = new MatTableDataSource(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 { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
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 { MatTableModule } from '@angular/material/table';
|
||||||
|
|
||||||
import { AccessTableComponent } from './access-table.component';
|
import { AccessTableComponent } from './access-table.component';
|
||||||
@ -7,7 +9,7 @@ import { AccessTableComponent } from './access-table.component';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AccessTableComponent],
|
declarations: [AccessTableComponent],
|
||||||
exports: [AccessTableComponent],
|
exports: [AccessTableComponent],
|
||||||
imports: [CommonModule, MatTableModule],
|
imports: [CommonModule, MatButtonModule, MatMenuModule, MatTableModule],
|
||||||
providers: [],
|
providers: [],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -5,20 +5,26 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import {
|
import {
|
||||||
MatSlideToggle,
|
MatSlideToggle,
|
||||||
MatSlideToggleChange
|
MatSlideToggleChange
|
||||||
} from '@angular/material/slide-toggle';
|
} 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 { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
|
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
|
||||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { StripeService } from 'ngx-stripe';
|
import { StripeService } from 'ngx-stripe';
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { CreateOrUpdateAccessDialog } from './create-or-update-access-dialog/create-or-update-access-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'mb-5' },
|
host: { class: 'mb-5' },
|
||||||
selector: 'gf-account-page',
|
selector: 'gf-account-page',
|
||||||
@ -35,7 +41,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public couponId: string;
|
public couponId: string;
|
||||||
public currencies: string[] = [];
|
public currencies: string[] = [];
|
||||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
|
public deviceType: string;
|
||||||
public hasPermissionForSubscription: boolean;
|
public hasPermissionForSubscription: boolean;
|
||||||
|
public hasPermissionToCreateAccess: boolean;
|
||||||
|
public hasPermissionToDeleteAccess: boolean;
|
||||||
public hasPermissionToUpdateViewMode: boolean;
|
public hasPermissionToUpdateViewMode: boolean;
|
||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
public price: number;
|
public price: number;
|
||||||
@ -50,6 +59,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
private stripeService: StripeService,
|
private stripeService: StripeService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
public webAuthnService: WebAuthnService
|
public webAuthnService: WebAuthnService
|
||||||
@ -65,6 +78,11 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
permissions.enableSubscription
|
permissions.enableSubscription
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToDeleteAccess = hasPermission(
|
||||||
|
globalPermissions,
|
||||||
|
permissions.deleteAccess
|
||||||
|
);
|
||||||
|
|
||||||
this.price = subscriptions?.[0]?.price;
|
this.price = subscriptions?.[0]?.price;
|
||||||
this.priceId = subscriptions?.[0]?.priceId;
|
this.priceId = subscriptions?.[0]?.priceId;
|
||||||
|
|
||||||
@ -74,6 +92,16 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = 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.hasPermissionToUpdateUserSettings = hasPermission(
|
||||||
this.user.permissions,
|
this.user.permissions,
|
||||||
permissions.updateUserSettings
|
permissions.updateUserSettings
|
||||||
@ -87,12 +115,22 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (params['createDialog']) {
|
||||||
|
this.openCreateAccessDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the controller
|
* Initializes the controller
|
||||||
*/
|
*/
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
this.update();
|
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) {
|
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putUserSetting({ isRestrictedView: aEvent.checked })
|
.putUserSetting({ isRestrictedView: aEvent.checked })
|
||||||
@ -175,6 +224,38 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.complete();
|
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() {
|
private deregisterDevice() {
|
||||||
this.webAuthnService
|
this.webAuthnService
|
||||||
.deregister()
|
.deregister()
|
||||||
|
@ -132,10 +132,26 @@
|
|||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div *ngIf="accesses?.length > 0" class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h3 class="mb-3 text-center" i18n>Granted Access</h3>
|
<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>
|
</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>
|
</div>
|
||||||
|
@ -12,6 +12,7 @@ import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/acce
|
|||||||
|
|
||||||
import { AccountPageRoutingModule } from './account-page-routing.module';
|
import { AccountPageRoutingModule } from './account-page-routing.module';
|
||||||
import { AccountPageComponent } from './account-page.component';
|
import { AccountPageComponent } from './account-page.component';
|
||||||
|
import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-dialog/create-or-update-access-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AccountPageComponent],
|
declarations: [AccountPageComponent],
|
||||||
@ -20,6 +21,7 @@ import { AccountPageComponent } from './account-page.component';
|
|||||||
AccountPageRoutingModule,
|
AccountPageRoutingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
GfCreateOrUpdateAccessDialogModule,
|
||||||
GfPortfolioAccessTableModule,
|
GfPortfolioAccessTableModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
|
@ -2,6 +2,26 @@
|
|||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
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 {
|
.hint-text {
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
line-height: 1.2;
|
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 router: Router,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
this.routeQueryParams = route.queryParams
|
this.route.queryParams
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((params) => {
|
.subscribe((params) => {
|
||||||
if (params['createDialog']) {
|
if (params['createDialog']) {
|
||||||
|
@ -12,8 +12,8 @@
|
|||||||
<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 name="type" required [(value)]="data.account.accountType">
|
<mat-select name="type" required [(value)]="data.account.accountType">
|
||||||
<mat-option value="CASH" i18n>Cash</mat-option>
|
<mat-option i18n value="CASH">Cash</mat-option>
|
||||||
<mat-option value="SECURITIES" i18n>Securities</mat-option>
|
<mat-option i18n value="SECURITIES">Securities</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<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>
|
</div>
|
||||||
<div class="proportion-charts row">
|
<div class="proportion-charts row">
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
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 { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-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 { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||||
import {
|
import {
|
||||||
@ -69,6 +69,10 @@ export class DataService {
|
|||||||
return this.http.get<AdminData>('/api/admin');
|
return this.http.get<AdminData>('/api/admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public deleteAccess(aId: string) {
|
||||||
|
return this.http.delete<any>(`/api/access/${aId}`);
|
||||||
|
}
|
||||||
|
|
||||||
public deleteAccount(aId: string) {
|
public deleteAccount(aId: string) {
|
||||||
return this.http.delete<any>(`/api/account/${aId}`);
|
return this.http.delete<any>(`/api/account/${aId}`);
|
||||||
}
|
}
|
||||||
@ -197,6 +201,10 @@ export class DataService {
|
|||||||
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`);
|
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) {
|
public postAccount(aAccount: CreateAccountDto) {
|
||||||
return this.http.post<OrderModel>(`/api/account`, aAccount);
|
return this.http.post<OrderModel>(`/api/account`, aAccount);
|
||||||
}
|
}
|
||||||
|
@ -7,9 +7,11 @@ export function isApiTokenAuthorized(aApiToken: string) {
|
|||||||
export const permissions = {
|
export const permissions = {
|
||||||
accessAdminControl: 'accessAdminControl',
|
accessAdminControl: 'accessAdminControl',
|
||||||
accessFearAndGreedIndex: 'accessFearAndGreedIndex',
|
accessFearAndGreedIndex: 'accessFearAndGreedIndex',
|
||||||
|
createAccess: 'createAccess',
|
||||||
createAccount: 'createAccount',
|
createAccount: 'createAccount',
|
||||||
createOrder: 'createOrder',
|
createOrder: 'createOrder',
|
||||||
createUserAccount: 'createUserAccount',
|
createUserAccount: 'createUserAccount',
|
||||||
|
deleteAccess: 'deleteAccess',
|
||||||
deleteAccount: 'deleteAcccount',
|
deleteAccount: 'deleteAcccount',
|
||||||
deleteAuthDevice: 'deleteAuthDevice',
|
deleteAuthDevice: 'deleteAuthDevice',
|
||||||
deleteOrder: 'deleteOrder',
|
deleteOrder: 'deleteOrder',
|
||||||
@ -38,8 +40,10 @@ export function getPermissions(aRole: Role): string[] {
|
|||||||
case 'ADMIN':
|
case 'ADMIN':
|
||||||
return [
|
return [
|
||||||
permissions.accessAdminControl,
|
permissions.accessAdminControl,
|
||||||
|
permissions.createAccess,
|
||||||
permissions.createAccount,
|
permissions.createAccount,
|
||||||
permissions.createOrder,
|
permissions.createOrder,
|
||||||
|
permissions.deleteAccess,
|
||||||
permissions.deleteAccount,
|
permissions.deleteAccount,
|
||||||
permissions.deleteAuthDevice,
|
permissions.deleteAuthDevice,
|
||||||
permissions.deleteOrder,
|
permissions.deleteOrder,
|
||||||
@ -56,8 +60,10 @@ export function getPermissions(aRole: Role): string[] {
|
|||||||
|
|
||||||
case 'USER':
|
case 'USER':
|
||||||
return [
|
return [
|
||||||
|
permissions.createAccess,
|
||||||
permissions.createAccount,
|
permissions.createAccount,
|
||||||
permissions.createOrder,
|
permissions.createOrder,
|
||||||
|
permissions.deleteAccess,
|
||||||
permissions.deleteAccount,
|
permissions.deleteAccount,
|
||||||
permissions.deleteAuthDevice,
|
permissions.deleteAuthDevice,
|
||||||
permissions.deleteOrder,
|
permissions.deleteOrder,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user