Browse Source

Add write access

pull/7676/head
Thomas Kaul 1 week ago
parent
commit
3d48fbeb3e
  1. 12
      apps/client/src/app/components/home-overview/home-overview.component.ts
  2. 6
      apps/client/src/app/components/home-overview/home-overview.html
  3. 10
      apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts
  4. 21
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  5. 5
      apps/client/src/app/pages/accounts/accounts-page.html
  6. 6
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  7. 13
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  8. 10
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts
  9. 8
      libs/common/src/lib/scopes.spec.ts
  10. 44
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  11. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  12. 28
      libs/ui/src/lib/activities-table/activities-table.component.html
  13. 2
      libs/ui/src/lib/activities-table/activities-table.component.ts

12
apps/client/src/app/components/home-overview/home-overview.component.ts

@ -1,5 +1,6 @@
import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.component'; import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.component';
import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
@ -46,6 +47,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
}) })
export class GfHomeOverviewComponent implements OnInit { export class GfHomeOverviewComponent implements OnInit {
protected readonly errors = signal<AssetProfileIdentifier[]>([]); protected readonly errors = signal<AssetProfileIdentifier[]>([]);
protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal<LineChartItem[] | null>(null); protected readonly historicalDataItems = signal<LineChartItem[] | null>(null);
protected readonly isLoadingPerformance = signal(true); protected readonly isLoadingPerformance = signal(true);
protected readonly performance = signal<PortfolioPerformance | null>(null); protected readonly performance = signal<PortfolioPerformance | null>(null);
@ -88,6 +90,9 @@ export class GfHomeOverviewComponent implements OnInit {
private readonly dataService = inject(DataService); private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly layoutService = inject(LayoutService); private readonly layoutService = inject(LayoutService);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
@ -103,6 +108,13 @@ export class GfHomeOverviewComponent implements OnInit {
} }
public ngOnInit() { public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId.set(!!impersonationId);
});
this.layoutService.shouldReloadContent$ this.layoutService.shouldReloadContent$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => { .subscribe(() => {

6
apps/client/src/app/components/home-overview/home-overview.html

@ -1,7 +1,11 @@
<div <div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
> >
@if (hasPermissionToCreateActivity() && user()?.activitiesCount === 0) { @if (
!hasImpersonationId() &&
hasPermissionToCreateActivity() &&
user()?.activitiesCount === 0
) {
<div class="justify-content-center row w-100"> <div class="justify-content-center row w-100">
<div class="col introduction"> <div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4> <h4 i18n>Welcome to Ghostfolio</h4>

10
apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts

@ -9,6 +9,7 @@ import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import { AccountResponse, User } from '@ghostfolio/common/interfaces'; import { AccountResponse, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Scope, hasScope, scopes } from '@ghostfolio/common/scopes';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
@ -111,7 +112,7 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
if ( if (
!account || !account ||
!hasPermission(user?.permissions, permissions.updateAccount) || !hasPermission(user?.permissions, permissions.updateAccount) ||
this.isReadOnlyMode(user) this.isReadOnlyMode(user, scopes.accountUpdate)
) { ) {
this.navigateBack(); this.navigateBack();
@ -140,7 +141,7 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
if ( if (
!hasPermission(user?.permissions, permissions.createAccount) || !hasPermission(user?.permissions, permissions.createAccount) ||
this.isReadOnlyMode(user) this.isReadOnlyMode(user, scopes.accountCreate)
) { ) {
this.navigateBack(); this.navigateBack();
@ -181,9 +182,10 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
this.dialogRef?.close(); this.dialogRef?.close();
} }
private isReadOnlyMode(user: User) { private isReadOnlyMode(user: User, requiredScope: Scope) {
return ( return (
!!this.impersonationStorageService.getId() || (!!this.impersonationStorageService.getId() &&
!hasScope(user?.scopes, requiredScope)) ||
!!user?.settings?.isRestrictedView !!user?.settings?.isRestrictedView
); );
} }

21
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -1,4 +1,3 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TransferBalanceDto } from '@ghostfolio/common/dtos'; import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
@ -42,8 +41,8 @@ export class GfAccountsPageComponent implements OnInit {
protected accounts: AccountWithValue[]; protected accounts: AccountWithValue[];
protected activitiesCount = 0; protected activitiesCount = 0;
protected hasPermissionToCreateAccount: boolean; protected hasPermissionToCreateAccount: boolean;
protected hasPermissionToDeleteAccount: boolean;
protected hasPermissionToUpdateAccount: boolean; protected hasPermissionToUpdateAccount: boolean;
protected impersonationId: string | null;
protected readonly internalRoutes = internalRoutes; protected readonly internalRoutes = internalRoutes;
protected totalBalanceInBaseCurrency = 0; protected totalBalanceInBaseCurrency = 0;
protected totalValueInBaseCurrency = 0; protected totalValueInBaseCurrency = 0;
@ -60,9 +59,6 @@ export class GfAccountsPageComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly notificationService = inject(NotificationService); private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
@ -78,18 +74,7 @@ export class GfAccountsPageComponent implements OnInit {
}); });
} }
protected get hasImpersonationId() {
return !!this.impersonationId;
}
public ngOnInit() { public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.impersonationId = impersonationId;
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -100,6 +85,10 @@ export class GfAccountsPageComponent implements OnInit {
hasPermission(this.user.permissions, permissions.createAccount) && hasPermission(this.user.permissions, permissions.createAccount) &&
hasScope(this.user.scopes, scopes.accountCreate); hasScope(this.user.scopes, scopes.accountCreate);
this.hasPermissionToDeleteAccount =
hasPermission(this.user.permissions, permissions.deleteAccount) &&
hasScope(this.user.scopes, scopes.accountDelete);
this.hasPermissionToUpdateAccount = this.hasPermissionToUpdateAccount =
hasPermission(this.user.permissions, permissions.updateAccount) && hasPermission(this.user.permissions, permissions.updateAccount) &&
hasScope(this.user.scopes, scopes.accountUpdate); hasScope(this.user.scopes, scopes.accountUpdate);

5
apps/client/src/app/pages/accounts/accounts-page.html

@ -6,9 +6,12 @@
[accounts]="accounts" [accounts]="accounts"
[activitiesCount]="activitiesCount" [activitiesCount]="activitiesCount"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[hasPermissionToDeleteAccount]="hasPermissionToDeleteAccount"
[hasPermissionToUpdateAccount]="hasPermissionToUpdateAccount"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]=" [showActions]="
hasPermissionToUpdateAccount && !user.settings.isRestrictedView (hasPermissionToDeleteAccount || hasPermissionToUpdateAccount) &&
!user.settings.isRestrictedView
" "
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency" [totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency" [totalValueInBaseCurrency]="totalValueInBaseCurrency"

6
apps/client/src/app/pages/portfolio/activities/activities-page.component.ts

@ -54,6 +54,7 @@ export class GfActivitiesPageComponent implements OnInit {
protected hasImpersonationId: boolean; protected hasImpersonationId: boolean;
protected hasPermissionToCreateActivity: boolean; protected hasPermissionToCreateActivity: boolean;
protected hasPermissionToDeleteActivity: boolean; protected hasPermissionToDeleteActivity: boolean;
protected hasPermissionToUpdateActivity: boolean;
protected readonly internalRoutes = internalRoutes; protected readonly internalRoutes = internalRoutes;
protected pageIndex = 0; protected pageIndex = 0;
protected readonly pageSize = DEFAULT_PAGE_SIZE; protected readonly pageSize = DEFAULT_PAGE_SIZE;
@ -302,6 +303,7 @@ export class GfActivitiesPageComponent implements OnInit {
this.totalItems = count; this.totalItems = count;
if ( if (
!this.hasImpersonationId &&
this.hasPermissionToCreateActivity && this.hasPermissionToCreateActivity &&
this.user?.activitiesCount === 0 this.user?.activitiesCount === 0
) { ) {
@ -338,5 +340,9 @@ export class GfActivitiesPageComponent implements OnInit {
this.hasPermissionToDeleteActivity = this.hasPermissionToDeleteActivity =
hasPermission(this.user.permissions, permissions.deleteActivity) && hasPermission(this.user.permissions, permissions.deleteActivity) &&
hasScope(this.user.scopes, scopes.activityDelete); hasScope(this.user.scopes, scopes.activityDelete);
this.hasPermissionToUpdateActivity =
hasPermission(this.user.permissions, permissions.updateActivity) &&
hasScope(this.user.scopes, scopes.activityUpdate);
} }
} }

13
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -7,15 +7,24 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasActivities]="user?.activitiesCount > 0" [hasActivities]="user?.activitiesCount > 0"
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity" [hasPermissionToCreateActivity]="
hasPermissionToCreateActivity && !hasImpersonationId
"
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity" [hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilterByType]="user?.settings?.isExperimentalFeatures" [hasPermissionToFilterByType]="user?.settings?.isExperimentalFeatures"
[hasPermissionToImportActivities]="
hasPermissionToCreateActivity && !hasImpersonationId
"
[hasPermissionToUpdateActivity]="hasPermissionToUpdateActivity"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"
[showActions]=" [showActions]="
hasPermissionToDeleteActivity && !user.settings.isRestrictedView (hasPermissionToCreateActivity ||
hasPermissionToDeleteActivity ||
hasPermissionToUpdateActivity) &&
!user.settings.isRestrictedView
" "
[sortColumn]="sortColumn" [sortColumn]="sortColumn"
[sortDirection]="sortDirection" [sortDirection]="sortDirection"

10
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts

@ -4,6 +4,7 @@ import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { Activity, User } from '@ghostfolio/common/interfaces'; import { Activity, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Scope, hasScope, scopes } from '@ghostfolio/common/scopes';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { import {
@ -94,7 +95,7 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
if ( if (
!activity || !activity ||
!hasPermission(user?.permissions, permissions.updateActivity) || !hasPermission(user?.permissions, permissions.updateActivity) ||
this.isReadOnlyMode(user) this.isReadOnlyMode(user, scopes.activityUpdate)
) { ) {
this.navigateBack(); this.navigateBack();
@ -115,7 +116,7 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
// Cloning creates a new activity as well // Cloning creates a new activity as well
if ( if (
!hasPermission(user?.permissions, permissions.createActivity) || !hasPermission(user?.permissions, permissions.createActivity) ||
this.isReadOnlyMode(user) this.isReadOnlyMode(user, scopes.activityCreate)
) { ) {
this.navigateBack(); this.navigateBack();
@ -156,9 +157,10 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
this.dialogRef?.close(); this.dialogRef?.close();
} }
private isReadOnlyMode(user: User) { private isReadOnlyMode(user: User, requiredScope: Scope) {
return ( return (
!!this.impersonationStorageService.getId() || (!!this.impersonationStorageService.getId() &&
!hasScope(user?.scopes, requiredScope)) ||
!!user?.settings?.isRestrictedView !!user?.settings?.isRestrictedView
); );
} }

8
libs/common/src/lib/scopes.spec.ts

@ -136,6 +136,14 @@ describe('Scopes', () => {
getScopesOfAccess({ scopes: [...SCOPES_OF_READ_ACCESS] }) getScopesOfAccess({ scopes: [...SCOPES_OF_READ_ACCESS] })
).not.toContain(scopes.portfolioReadValues); ).not.toContain(scopes.portfolioReadValues);
}); });
// The dialog offers the write scopes for a private access only, hence this
// function is the sole barrier for a public access
it('Gives no write scope', () => {
expect(
getScopesOfAccess({ scopes: [...SCOPES_OF_WRITE_ACCESS] })
).toEqual([]);
});
}); });
describe('Get scopes of own access', () => { describe('Get scopes of own access', () => {

44
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -338,26 +338,30 @@
</span> </span>
</a> </a>
} }
<a @if (hasPermissionToUpdateAccount()) {
mat-menu-item <a
[routerLink]="accountDialogRouterLinks().get(element.id)?.update" mat-menu-item
> [routerLink]="accountDialogRouterLinks().get(element.id)?.update"
<span class="align-items-center d-flex"> >
<ion-icon class="mr-2" name="create-outline" /> <span class="align-items-center d-flex">
<span><ng-container i18n>Edit</ng-container>...</span> <ion-icon class="mr-2" name="create-outline" />
</span> <span><ng-container i18n>Edit</ng-container>...</span>
</a> </span>
<hr class="m-0" /> </a>
<button }
mat-menu-item @if (hasPermissionToDeleteAccount()) {
[disabled]="element.activitiesCount > 0" <hr class="m-0" />
(click)="onDeleteAccount(element.id)" <button
> mat-menu-item
<span class="align-items-center d-flex"> [disabled]="element.activitiesCount > 0"
<ion-icon class="mr-2" name="trash-outline" /> (click)="onDeleteAccount(element.id)"
<span i18n>Delete</span> >
</span> <span class="align-items-center d-flex">
</button> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button>
}
</mat-menu> </mat-menu>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td> <td *matFooterCellDef class="px-1" mat-footer-cell></td>

2
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -59,7 +59,9 @@ export class GfAccountsTableComponent {
public readonly accounts = input.required<AccountWithValue[]>(); public readonly accounts = input.required<AccountWithValue[]>();
public readonly activitiesCount = input<number>(); public readonly activitiesCount = input<number>();
public readonly baseCurrency = input<string>(); public readonly baseCurrency = input<string>();
public readonly hasPermissionToDeleteAccount = input<boolean>();
public readonly hasPermissionToOpenDetails = input(true); public readonly hasPermissionToOpenDetails = input(true);
public readonly hasPermissionToUpdateAccount = input<boolean>();
public readonly locale = input(getLocale()); public readonly locale = input(getLocale());
public readonly showActions = input<boolean>(); public readonly showActions = input<boolean>();
public readonly showActivitiesCount = input(true); public readonly showActivitiesCount = input(true);

28
libs/ui/src/lib/activities-table/activities-table.component.html

@ -19,7 +19,7 @@
} }
</div> </div>
@if (hasPermissionToCreateActivity) { @if (hasPermissionToImportActivities) {
<div class="d-flex"> <div class="d-flex">
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
@ -397,7 +397,7 @@
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell> <th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
@if ( @if (
!hasPermissionToCreateActivity && hasPermissionToExportActivities !hasPermissionToImportActivities && hasPermissionToExportActivities
) { ) {
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
@ -413,7 +413,7 @@
class="no-max-width" class="no-max-width"
xPosition="before" xPosition="before"
> >
@if (hasPermissionToCreateActivity) { @if (hasPermissionToImportActivities) {
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
@ -427,7 +427,7 @@
</span> </span>
</button> </button>
} }
@if (hasPermissionToCreateActivity) { @if (hasPermissionToImportActivities) {
<button <button
mat-menu-item mat-menu-item
[disabled]="dataSource()?.data.length === 0" [disabled]="dataSource()?.data.length === 0"
@ -493,15 +493,17 @@
</span> </span>
</button> </button>
} }
<a @if (hasPermissionToUpdateActivity) {
mat-menu-item <a
[routerLink]="activityDialogRouterLinks().get(element.id)?.update" mat-menu-item
> [routerLink]="activityDialogRouterLinks().get(element.id)?.update"
<span class="align-items-center d-flex"> >
<ion-icon class="mr-2" name="create-outline" /> <span class="align-items-center d-flex">
<span><ng-container i18n>Edit</ng-container>...</span> <ion-icon class="mr-2" name="create-outline" />
</span> <span><ng-container i18n>Edit</ng-container>...</span>
</a> </span>
</a>
}
<a <a
mat-menu-item mat-menu-item
[routerLink]="activityDialogRouterLinks().get(element.id)?.clone" [routerLink]="activityDialogRouterLinks().get(element.id)?.clone"

2
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -114,7 +114,9 @@ export class GfActivitiesTableComponent implements AfterViewInit, OnInit {
@Input() hasPermissionToDeleteActivity: boolean; @Input() hasPermissionToDeleteActivity: boolean;
@Input() hasPermissionToExportActivities: boolean; @Input() hasPermissionToExportActivities: boolean;
@Input() hasPermissionToFilterByType: boolean; @Input() hasPermissionToFilterByType: boolean;
@Input() hasPermissionToImportActivities: boolean;
@Input() hasPermissionToOpenDetails = true; @Input() hasPermissionToOpenDetails = true;
@Input() hasPermissionToUpdateActivity = true;
@Input() locale = getLocale(); @Input() locale = getLocale();
@Input() pageIndex: number; @Input() pageIndex: number;
@Input() pageSize = DEFAULT_PAGE_SIZE; @Input() pageSize = DEFAULT_PAGE_SIZE;

Loading…
Cancel
Save