From 67dd8fbf0fd6d58b8a5b215c1a3e392f7fc1e6fb Mon Sep 17 00:00:00 2001
From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
Date: Fri, 21 Aug 2026 20:31:50 +0200
Subject: [PATCH] Task/add write access to create or update access dialog
(#7676)
* Add write access
* Update changelog
---
CHANGELOG.md | 4 +
.../access-table/access-table.component.html | 5 +-
.../access-table/access-table.component.ts | 10 +-
.../account-detail-dialog.html | 4 +-
.../interfaces/interfaces.ts | 1 +
.../holding-detail-dialog.html | 4 +-
.../home-holdings/home-holdings.component.ts | 22 +--
.../home-holdings/home-holdings.html | 6 +-
.../home-overview/home-overview.component.ts | 6 +-
.../home-watchlist.component.ts | 28 ++--
.../home-watchlist/home-watchlist.html | 2 +-
...reate-or-update-access-dialog.component.ts | 69 +++++---
.../create-or-update-access-dialog.html | 12 +-
.../interfaces/interfaces.ts | 3 +
.../account-dialog-host.component.ts | 13 +-
.../pages/accounts/accounts-page.component.ts | 30 ++--
.../src/app/pages/accounts/accounts-page.html | 11 +-
.../activities/activities-page.component.ts | 21 ++-
.../portfolio/activities/activities-page.html | 13 +-
.../activity-dialog-host.component.ts | 13 +-
.../allocations/allocations-page.component.ts | 4 +
libs/common/src/lib/scopes.spec.ts | 40 ++++-
libs/common/src/lib/scopes.ts | 11 +-
.../accounts-table.component.html | 46 ++---
.../accounts-table.component.ts | 2 +
.../activities-table.component.html | 158 ++++++++++--------
.../activities-table.component.ts | 2 +
27 files changed, 330 insertions(+), 210 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5642465c0..8faa1260f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
+### Added
+
+- Added the permission to manage data to the grant access dialog and to the access table to share the portfolio (experimental)
+
### Changed
- Extended the account selector options to include the platform and currency
diff --git a/apps/client/src/app/components/access-table/access-table.component.html b/apps/client/src/app/components/access-table/access-table.component.html
index 77da69d23..38122c39c 100644
--- a/apps/client/src/app/components/access-table/access-table.component.html
+++ b/apps/client/src/app/components/access-table/access-table.component.html
@@ -18,7 +18,10 @@
Permission |
- @if (hasScopeToReadValues(element)) {
+ @if (hasScopesToWrite(element)) {
+
+ View and manage
+ } @else if (hasScopeToReadValues(element)) {
View
} @else {
diff --git a/apps/client/src/app/components/access-table/access-table.component.ts b/apps/client/src/app/components/access-table/access-table.component.ts
index 3886fc59c..58a9e8034 100644
--- a/apps/client/src/app/components/access-table/access-table.component.ts
+++ b/apps/client/src/app/components/access-table/access-table.component.ts
@@ -1,7 +1,11 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
-import { hasScope, scopes } from '@ghostfolio/common/scopes';
+import {
+ hasAnyScopeOfWriteAccess,
+ hasScope,
+ scopes
+} from '@ghostfolio/common/scopes';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
@@ -105,6 +109,10 @@ export class GfAccessTableComponent {
return hasScope(scopesOfAccess, scopes.portfolioReadValues);
}
+ protected hasScopesToWrite({ scopes: scopesOfAccess }: Access) {
+ return hasAnyScopeOfWriteAccess(scopesOfAccess);
+ }
+
protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId));
diff --git a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
index 1ca0a27dc..a87179ecb 100644
--- a/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
+++ b/apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
@@ -148,11 +148,13 @@
[baseCurrency]="user?.settings?.baseCurrency"
[dataSource]="dataSource"
[deviceType]="data.deviceType"
- [hasPermissionToCreateActivity]="false"
+ [hasActivities]="true"
+ [hasPermissionToCreateActivity]="data.hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="!showValuesInPercentage()"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
+ [hasPermissionToUpdateActivity]="data.hasPermissionToUpdateActivity"
[locale]="user?.settings?.locale"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
diff --git a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
index 0e7d04f2c..6036a503f 100644
--- a/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
+++ b/apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
@@ -2,6 +2,7 @@ export interface AccountDetailDialogParams {
accountId: string;
deviceType: string;
hasPermissionToCreateActivity: boolean;
+ hasPermissionToUpdateActivity: boolean;
impersonationId: string | null;
}
diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
index e6884c003..35a8ed3aa 100644
--- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
+++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
@@ -379,13 +379,15 @@
[baseCurrency]="data.baseCurrency"
[dataSource]="dataSource"
[deviceType]="data.deviceType"
- [hasPermissionToCreateActivity]="false"
+ [hasActivities]="true"
+ [hasPermissionToCreateActivity]="data.hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="
!data.impersonationId && !user?.settings?.isRestrictedView
"
[hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false"
+ [hasPermissionToUpdateActivity]="data.hasPermissionToUpdateActivity"
[locale]="data.locale"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
diff --git a/apps/client/src/app/components/home-holdings/home-holdings.component.ts b/apps/client/src/app/components/home-holdings/home-holdings.component.ts
index 45dcd1a97..ca6351429 100644
--- a/apps/client/src/app/components/home-holdings/home-holdings.component.ts
+++ b/apps/client/src/app/components/home-holdings/home-holdings.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 {
AssetProfileIdentifier,
@@ -7,6 +6,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
+import { hasScope, scopes } from '@ghostfolio/common/scopes';
import {
HoldingType,
HoldingsViewMode,
@@ -51,7 +51,6 @@ export class GfHomeHoldingsComponent implements OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
protected deviceType: string;
- protected hasImpersonationId: boolean;
protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined;
@@ -83,9 +82,6 @@ export class GfHomeHoldingsComponent implements OnInit {
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
- private readonly impersonationStorageService = inject(
- ImpersonationStorageService
- );
private readonly router = inject(Router);
private readonly userService = inject(UserService);
@@ -96,15 +92,6 @@ export class GfHomeHoldingsComponent implements OnInit {
public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
- this.impersonationStorageService
- .onChangeHasImpersonation()
- .pipe(takeUntilDestroyed(this.destroyRef))
- .subscribe((impersonationId) => {
- this.hasImpersonationId = !!impersonationId;
-
- this.changeDetectorRef.markForCheck();
- });
-
this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
@@ -116,10 +103,9 @@ export class GfHomeHoldingsComponent implements OnInit {
permissions.accessHoldingsChart
);
- this.hasPermissionToCreateActivity = hasPermission(
- this.user.permissions,
- permissions.createActivity
- );
+ this.hasPermissionToCreateActivity =
+ hasPermission(this.user.permissions, permissions.createActivity) &&
+ hasScope(this.user.scopes, scopes.activityCreate);
this.initialize();
}
diff --git a/apps/client/src/app/components/home-holdings/home-holdings.html b/apps/client/src/app/components/home-holdings/home-holdings.html
index d3b24e7af..5c6a95d8e 100644
--- a/apps/client/src/app/components/home-holdings/home-holdings.html
+++ b/apps/client/src/app/components/home-holdings/home-holdings.html
@@ -41,11 +41,7 @@
[locale]="user?.settings?.locale"
(holdingClicked)="onHoldingClicked($event)"
/>
- @if (
- !hasImpersonationId &&
- hasPermissionToCreateActivity &&
- holdings?.length > 0
- ) {
+ @if (hasPermissionToCreateActivity && holdings?.length > 0) {
{
- return hasPermission(this.user()?.permissions, permissions.createActivity);
+ return (
+ hasPermission(this.user()?.permissions, permissions.createActivity) &&
+ hasScope(this.user()?.scopes, scopes.activityCreate)
+ );
});
protected readonly showDetails = computed(() => {
diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts b/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
index 7492e4e92..91cab29a9 100644
--- a/apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
+++ b/apps/client/src/app/components/home-watchlist/home-watchlist.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 { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import {
@@ -7,6 +6,7 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
+import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@@ -44,7 +44,6 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
templateUrl: './home-watchlist.html'
})
export class GfHomeWatchlistComponent implements OnInit {
- protected hasImpersonationId: boolean;
protected hasPermissionToCreateWatchlistItem: boolean;
protected hasPermissionToDeleteWatchlistItem: boolean;
protected user: User;
@@ -59,21 +58,11 @@ export class GfHomeWatchlistComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
- private readonly impersonationStorageService = inject(
- ImpersonationStorageService
- );
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
- this.impersonationStorageService
- .onChangeHasImpersonation()
- .pipe(takeUntilDestroyed(this.destroyRef))
- .subscribe((impersonationId) => {
- this.hasImpersonationId = !!impersonationId;
- });
-
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
@@ -89,17 +78,15 @@ export class GfHomeWatchlistComponent implements OnInit {
this.user = state.user;
this.hasPermissionToCreateWatchlistItem =
- !this.hasImpersonationId &&
hasPermission(
this.user.permissions,
permissions.createWatchlistItem
- );
+ ) && hasScope(this.user.scopes, scopes.watchlistCreate);
this.hasPermissionToDeleteWatchlistItem =
- !this.hasImpersonationId &&
hasPermission(
this.user.permissions,
permissions.deleteWatchlistItem
- );
+ ) && hasScope(this.user.scopes, scopes.watchlistDelete);
this.changeDetectorRef.markForCheck();
}
@@ -149,6 +136,15 @@ export class GfHomeWatchlistComponent implements OnInit {
.subscribe((user) => {
this.user = user;
+ if (
+ !hasPermission(user?.permissions, permissions.createWatchlistItem) ||
+ !hasScope(user?.scopes, scopes.watchlistCreate)
+ ) {
+ this.router.navigate(['.'], { relativeTo: this.route });
+
+ return;
+ }
+
const dialogRef = this.dialog.open<
GfCreateWatchlistItemDialogComponent,
CreateWatchlistItemDialogParams
diff --git a/apps/client/src/app/components/home-watchlist/home-watchlist.html b/apps/client/src/app/components/home-watchlist/home-watchlist.html
index e2865b9cf..cc27cdc42 100644
--- a/apps/client/src/app/components/home-watchlist/home-watchlist.html
+++ b/apps/client/src/app/components/home-watchlist/home-watchlist.html
@@ -21,6 +21,6 @@
-@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) {
+@if (hasPermissionToCreateWatchlistItem) {
}
diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
index 12425b50e..40c4fe703 100644
--- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
+++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
@@ -4,6 +4,9 @@ import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import {
SCOPES_OF_READ_ACCESS,
SCOPES_OF_READ_RESTRICTED_ACCESS,
+ SCOPES_OF_WRITE_ACCESS,
+ Scope,
+ hasAnyScopeOfWriteAccess,
hasScope,
scopes
} from '@ghostfolio/common/scopes';
@@ -49,7 +52,10 @@ import { MatSelectModule } from '@angular/material/select';
import { StatusCodes } from 'http-status-codes';
import { EMPTY, catchError } from 'rxjs';
-import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
+import {
+ AccessLevel,
+ CreateOrUpdateAccessDialogParams
+} from './interfaces/interfaces';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -105,21 +111,22 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
);
}
+ public get canGrantWriteAccess() {
+ return this.hasExperimentalFeatures;
+ }
+
public ngOnInit() {
const access = this.data?.access;
const isPublic = access?.type === 'PUBLIC';
this.accessForm = this.formBuilder.group({
+ accessLevel: this.getAccessLevel(access?.scopes),
alias: [access?.alias ?? ''],
filters: [null],
granteeUserId: [
access?.grantee ?? null,
isPublic ? null : Validators.required
],
- hasScopeToReadValues: hasScope(
- access?.scopes,
- scopes.portfolioReadValues
- ),
type: [
{ disabled: this.mode === 'update', value: access?.type ?? 'PRIVATE' },
Validators.required
@@ -145,10 +152,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
.subscribe((accessType) => {
const granteeUserIdControl = this.accessForm.get('granteeUserId');
- const hasScopeToReadValuesControl = this.accessForm.get(
- 'hasScopeToReadValues'
- );
-
if (accessType === 'PRIVATE') {
granteeUserIdControl?.setValidators(Validators.required);
this.accessForm.get('filters')?.setValue(null);
@@ -156,8 +159,9 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
granteeUserIdControl?.clearValidators();
granteeUserIdControl?.setValue(null);
- // A public access never exposes the monetary values
- hasScopeToReadValuesControl?.setValue(false);
+ // A public access never exposes the monetary values and never
+ // changes data
+ this.accessForm.get('accessLevel')?.setValue('READ_RESTRICTED');
}
granteeUserIdControl?.updateValueAndValidity();
@@ -186,16 +190,29 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
);
}
- /**
- * The dialog offers the read access only. The write scopes are not granted
- * here yet.
- */
- private buildScopes() {
- return [
- ...(this.accessForm.get('hasScopeToReadValues')?.value
- ? SCOPES_OF_READ_ACCESS
- : SCOPES_OF_READ_RESTRICTED_ACCESS)
- ];
+ private buildScopes(): Scope[] {
+ const accessLevel = this.accessForm.get('accessLevel')
+ ?.value as AccessLevel;
+
+ const scopesOfAccess = this.data.access?.scopes ?? [];
+
+ if (
+ scopesOfAccess.length > 0 &&
+ accessLevel === this.getAccessLevel(scopesOfAccess)
+ ) {
+ return Object.values(scopes).filter((scope) => {
+ return hasScope(scopesOfAccess, scope);
+ });
+ }
+
+ switch (accessLevel) {
+ case 'CREATE_READ_UPDATE_DELETE':
+ return [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS];
+ case 'READ':
+ return [...SCOPES_OF_READ_ACCESS];
+ default:
+ return [...SCOPES_OF_READ_RESTRICTED_ACCESS];
+ }
}
private async createAccess() {
@@ -237,6 +254,16 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}
}
+ private getAccessLevel(scopesOfAccess: string[] | undefined): AccessLevel {
+ if (hasAnyScopeOfWriteAccess(scopesOfAccess)) {
+ return 'CREATE_READ_UPDATE_DELETE';
+ }
+
+ return hasScope(scopesOfAccess, scopes.portfolioReadValues)
+ ? 'READ'
+ : 'READ_RESTRICTED';
+ }
+
private loadHoldings() {
this.dataService
.fetchPortfolioHoldings()
diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
index 601548e98..545dde228 100644
--- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
+++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
@@ -36,10 +36,16 @@
Permission
-
- Restricted view
+
+ Restricted view
@if (accessForm.get('type')?.value === 'PRIVATE') {
- View
+ View
+ View and manage
}
diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
index 8d1ac0ba9..ca7cee25a 100644
--- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
+++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
@@ -3,3 +3,6 @@ import { Access } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams {
access?: Access;
}
+
+export type AccessLevel =
+ 'CREATE_READ_UPDATE_DELETE' | 'READ' | 'READ_RESTRICTED';
diff --git a/apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts b/apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts
index 3734889a3..29f7eb78b 100644
--- a/apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts
+++ b/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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
+import { Scope, hasScope, scopes } from '@ghostfolio/common/scopes';
import { DataService } from '@ghostfolio/ui/services';
import {
@@ -111,7 +112,7 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
if (
!account ||
!hasPermission(user?.permissions, permissions.updateAccount) ||
- this.isReadOnlyMode(user)
+ this.isWriteRestricted(user, scopes.accountUpdate)
) {
this.navigateBack();
@@ -140,7 +141,7 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
if (
!hasPermission(user?.permissions, permissions.createAccount) ||
- this.isReadOnlyMode(user)
+ this.isWriteRestricted(user, scopes.accountCreate)
) {
this.navigateBack();
@@ -181,9 +182,9 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
this.dialogRef?.close();
}
- private isReadOnlyMode(user: User) {
+ private isWriteRestricted(user: User, requiredScope: Scope) {
return (
- !!this.impersonationStorageService.getId() ||
+ !hasScope(user?.scopes, requiredScope) ||
!!user?.settings?.isRestrictedView
);
}
@@ -220,6 +221,10 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
hasPermissionToCreateActivity:
!impersonationId &&
hasPermission(user?.permissions, permissions.createActivity) &&
+ !user?.settings?.isRestrictedView,
+ hasPermissionToUpdateActivity:
+ !impersonationId &&
+ hasPermission(user?.permissions, permissions.updateActivity) &&
!user?.settings?.isRestrictedView
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
diff --git a/apps/client/src/app/pages/accounts/accounts-page.component.ts b/apps/client/src/app/pages/accounts/accounts-page.component.ts
index 0f21ef55e..41072948a 100644
--- a/apps/client/src/app/pages/accounts/accounts-page.component.ts
+++ b/apps/client/src/app/pages/accounts/accounts-page.component.ts
@@ -4,6 +4,7 @@ import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
+import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
@@ -41,13 +42,14 @@ export class GfAccountsPageComponent implements OnInit {
protected accounts: AccountWithValue[];
protected activitiesCount = 0;
protected hasPermissionToCreateAccount: boolean;
+ protected hasPermissionToDeleteAccount: boolean;
protected hasPermissionToUpdateAccount: boolean;
- protected impersonationId: string | null;
protected readonly internalRoutes = internalRoutes;
protected totalBalanceInBaseCurrency = 0;
protected totalValueInBaseCurrency = 0;
protected user: User;
+ private hasImpersonationId: boolean;
private isInitialFetch = true;
private readonly deviceType = computed(
@@ -77,16 +79,12 @@ export class GfAccountsPageComponent implements OnInit {
});
}
- protected get hasImpersonationId() {
- return !!this.impersonationId;
- }
-
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
- this.impersonationId = impersonationId;
+ this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
@@ -95,14 +93,17 @@ export class GfAccountsPageComponent implements OnInit {
if (state?.user) {
this.user = state.user;
- this.hasPermissionToCreateAccount = hasPermission(
- this.user.permissions,
- permissions.createAccount
- );
- this.hasPermissionToUpdateAccount = hasPermission(
- this.user.permissions,
- permissions.updateAccount
- );
+ this.hasPermissionToCreateAccount =
+ hasPermission(this.user.permissions, permissions.createAccount) &&
+ hasScope(this.user.scopes, scopes.accountCreate);
+
+ this.hasPermissionToDeleteAccount =
+ hasPermission(this.user.permissions, permissions.deleteAccount) &&
+ hasScope(this.user.scopes, scopes.accountDelete);
+
+ this.hasPermissionToUpdateAccount =
+ hasPermission(this.user.permissions, permissions.updateAccount) &&
+ hasScope(this.user.scopes, scopes.accountUpdate);
this.fetchAccounts();
}
@@ -148,6 +149,7 @@ export class GfAccountsPageComponent implements OnInit {
this.totalValueInBaseCurrency = totalValueInBaseCurrency;
if (
+ !this.hasImpersonationId &&
this.accounts?.length <= 0 &&
this.hasPermissionToCreateAccount &&
this.isInitialFetch
diff --git a/apps/client/src/app/pages/accounts/accounts-page.html b/apps/client/src/app/pages/accounts/accounts-page.html
index 8935f70f5..6b9adece1 100644
--- a/apps/client/src/app/pages/accounts/accounts-page.html
+++ b/apps/client/src/app/pages/accounts/accounts-page.html
@@ -6,10 +6,11 @@
[accounts]="accounts"
[activitiesCount]="activitiesCount"
[baseCurrency]="user?.settings?.baseCurrency"
+ [hasPermissionToDeleteAccount]="hasPermissionToDeleteAccount"
+ [hasPermissionToUpdateAccount]="hasPermissionToUpdateAccount"
[locale]="user?.settings?.locale"
[showActions]="
- !hasImpersonationId &&
- hasPermissionToUpdateAccount &&
+ (hasPermissionToDeleteAccount || hasPermissionToUpdateAccount) &&
!user.settings.isRestrictedView
"
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
@@ -20,11 +21,7 @@
- @if (
- !hasImpersonationId &&
- hasPermissionToCreateAccount &&
- !user.settings.isRestrictedView
- ) {
+ @if (hasPermissionToCreateAccount && !user.settings.isRestrictedView) {
diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
index c67629bb6..dbeedff32 100644
--- a/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
+++ b/apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
@@ -10,6 +10,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
+import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { DataService } from '@ghostfolio/ui/services';
@@ -53,6 +54,7 @@ export class GfActivitiesPageComponent implements OnInit {
protected hasImpersonationId: boolean;
protected hasPermissionToCreateActivity: boolean;
protected hasPermissionToDeleteActivity: boolean;
+ protected hasPermissionToUpdateActivity: boolean;
protected readonly internalRoutes = internalRoutes;
protected pageIndex = 0;
protected readonly pageSize = DEFAULT_PAGE_SIZE;
@@ -75,6 +77,12 @@ export class GfActivitiesPageComponent implements OnInit {
private readonly router = inject(Router);
private readonly userService = inject(UserService);
+ protected get hasPermissionToImportActivities() {
+ // An import always writes to the own portfolio, hence it is not available
+ // while the user impersonates a different user
+ return this.hasPermissionToCreateActivity && !this.hasImpersonationId;
+ }
+
public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
@@ -301,6 +309,7 @@ export class GfActivitiesPageComponent implements OnInit {
this.totalItems = count;
if (
+ !this.hasImpersonationId &&
this.hasPermissionToCreateActivity &&
this.user?.activitiesCount === 0
) {
@@ -330,12 +339,18 @@ export class GfActivitiesPageComponent implements OnInit {
this.user = aUser;
this.hasPermissionToCreateActivity =
- !this.hasImpersonationId &&
hasPermission(this.user.permissions, permissions.createActivity) &&
+ hasScope(this.user.scopes, scopes.activityCreate) &&
!this.user.settings?.isRestrictedView;
this.hasPermissionToDeleteActivity =
- !this.hasImpersonationId &&
- hasPermission(this.user.permissions, permissions.deleteActivity);
+ hasPermission(this.user.permissions, permissions.deleteActivity) &&
+ hasScope(this.user.scopes, scopes.activityDelete) &&
+ !this.user.settings?.isRestrictedView;
+
+ this.hasPermissionToUpdateActivity =
+ hasPermission(this.user.permissions, permissions.updateActivity) &&
+ hasScope(this.user.scopes, scopes.activityUpdate) &&
+ !this.user.settings?.isRestrictedView;
}
}
diff --git a/apps/client/src/app/pages/portfolio/activities/activities-page.html b/apps/client/src/app/pages/portfolio/activities/activities-page.html
index c69437742..e1c213183 100644
--- a/apps/client/src/app/pages/portfolio/activities/activities-page.html
+++ b/apps/client/src/app/pages/portfolio/activities/activities-page.html
@@ -11,12 +11,15 @@
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilterByType]="user?.settings?.isExperimentalFeatures"
+ [hasPermissionToImportActivities]="hasPermissionToImportActivities"
+ [hasPermissionToUpdateActivity]="hasPermissionToUpdateActivity"
[locale]="user?.settings?.locale"
[pageIndex]="pageIndex"
[pageSize]="pageSize"
[showActions]="
- !hasImpersonationId &&
- hasPermissionToDeleteActivity &&
+ (hasPermissionToCreateActivity ||
+ hasPermissionToDeleteActivity ||
+ hasPermissionToUpdateActivity) &&
!user.settings.isRestrictedView
"
[sortColumn]="sortColumn"
@@ -36,11 +39,7 @@
- @if (
- !hasImpersonationId &&
- hasPermissionToCreateActivity &&
- !user.settings.isRestrictedView
- ) {
+ @if (hasPermissionToCreateActivity) {
{
).toEqual([]);
});
- // TODO: Remove this expectation once the dialog allows to configure the
- // write scopes
- it('Gives no write scope', () => {
+ it('Gives the write scopes', () => {
const scopesOfAccess = getScopesOfAccess({
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d',
scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS]
});
for (const scope of SCOPES_OF_WRITE_ACCESS) {
- expect(scopesOfAccess).not.toContain(scope);
+ expect(scopesOfAccess).toContain(scope);
}
});
+
+ it('Drops an unknown scope', () => {
+ expect(
+ getScopesOfAccess({
+ granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d',
+ scopes: [scopes.portfolioRead, 'portfolio:write']
+ })
+ ).toEqual([scopes.portfolioRead]);
+ });
});
describe('Get scopes of public access', () => {
@@ -129,6 +137,14 @@ describe('Scopes', () => {
getScopesOfAccess({ scopes: [...SCOPES_OF_READ_ACCESS] })
).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', () => {
@@ -174,6 +190,22 @@ describe('Scopes', () => {
});
});
+ describe('Has any scope of write access', () => {
+ it('Single write scope', () => {
+ expect(hasAnyScopeOfWriteAccess([scopes.activityUpdate])).toEqual(true);
+ });
+
+ it('Read scopes only', () => {
+ expect(hasAnyScopeOfWriteAccess([...SCOPES_OF_READ_ACCESS])).toEqual(
+ false
+ );
+ });
+
+ it('Without scopes', () => {
+ expect(hasAnyScopeOfWriteAccess(undefined)).toEqual(false);
+ });
+ });
+
describe('Has scope', () => {
it('Present scope', () => {
expect(hasScope([scopes.portfolioRead], scopes.portfolioRead)).toEqual(
diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts
index 2c0a07e34..fa05f4290 100644
--- a/libs/common/src/lib/scopes.ts
+++ b/libs/common/src/lib/scopes.ts
@@ -67,8 +67,9 @@ export function getScopesOfAccess({
const scopesToEvaluate = scopesOfAccess ?? [];
if (granteeUserId) {
- // TODO: Permit the write scopes once the dialog allows to configure them
- return SCOPES_OF_READ_ACCESS.filter((scope) => {
+ // An unknown scope is dropped, so that a scope which has been removed from
+ // the vocabulary cannot stay effective
+ return Object.values(scopes).filter((scope) => {
return scopesToEvaluate.includes(scope);
});
}
@@ -96,6 +97,12 @@ export function getScopesOfUnrestrictedImpersonation(): string[] {
return [...SCOPES_OF_READ_RESTRICTED_ACCESS];
}
+export function hasAnyScopeOfWriteAccess(aScopes: string[] = []) {
+ return SCOPES_OF_WRITE_ACCESS.some((scope) => {
+ return hasScope(aScopes, scope);
+ });
+}
+
export function hasScope(aScopes: string[] = [], aScope: Scope) {
return aScopes.includes(aScope);
}
diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.html b/libs/ui/src/lib/accounts-table/accounts-table.component.html
index 0b4bb72ce..2719f05a0 100644
--- a/libs/ui/src/lib/accounts-table/accounts-table.component.html
+++ b/libs/ui/src/lib/accounts-table/accounts-table.component.html
@@ -1,4 +1,4 @@
-@if (showActions()) {
+@if (showActions() && hasPermissionToUpdateAccount()) {
|
|
diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.ts b/libs/ui/src/lib/accounts-table/accounts-table.component.ts
index cca3aa7b0..93b394951 100644
--- a/libs/ui/src/lib/accounts-table/accounts-table.component.ts
+++ b/libs/ui/src/lib/accounts-table/accounts-table.component.ts
@@ -59,7 +59,9 @@ export class GfAccountsTableComponent {
public readonly accounts = input.required();
public readonly activitiesCount = input();
public readonly baseCurrency = input();
+ public readonly hasPermissionToDeleteAccount = input();
public readonly hasPermissionToOpenDetails = input(true);
+ public readonly hasPermissionToUpdateAccount = input();
public readonly locale = input(getLocale());
public readonly showActions = input();
public readonly showActivitiesCount = input(true);
diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html
index 9c10b5cb7..20c6e34b7 100644
--- a/libs/ui/src/lib/activities-table/activities-table.component.html
+++ b/libs/ui/src/lib/activities-table/activities-table.component.html
@@ -19,17 +19,19 @@
}
- @if (hasPermissionToCreateActivity) {
+ @if (hasPermissionToImportActivities || hasPermissionToDeleteActivity) {
-
-
- Import Activities...
-
- @if (hasPermissionToExportActivities) {
+ @if (hasPermissionToImportActivities) {
+
+
+ Import Activities...
+
+ }
+ @if (hasPermissionToExportActivities || hasPermissionToDeleteActivity) {
-
-
-
- Import Dividends...
-
-
+ @if (hasPermissionToImportActivities) {
+
+
+
+ Import Dividends...
+
+
+ }
@if (hasPermissionToExportActivities) {
}
-
-
-
-
- @if (canDeleteActivities()) {
- Delete {{ totalItems > 1 ? totalItems : '' }}
- {totalItems, plural, =1 {Activity} other {Activities}}
- } @else {
- Delete Activities
- }
-
-
+ @if (hasPermissionToDeleteActivity) {
+ @if (
+ hasPermissionToExportActivities || hasPermissionToImportActivities
+ ) {
+
+ }
+
+
+
+ @if (canDeleteActivities()) {
+ Delete {{ totalItems > 1 ? totalItems : '' }}
+ {totalItems, plural, =1 {Activity} other {Activities}}
+ } @else {
+ Delete Activities
+ }
+
+
+ }
}
@@ -397,7 +407,7 @@
@if (
- !hasPermissionToCreateActivity && hasPermissionToExportActivities
+ !hasPermissionToImportActivities && hasPermissionToExportActivities
) {
- @if (hasPermissionToCreateActivity) {
+ @if (hasPermissionToImportActivities) {
}
- @if (hasPermissionToCreateActivity) {
+ @if (hasPermissionToImportActivities) {
}
-
-
-
- Edit...
-
-
-
-
-
- Clone...
-
-
-
-
-
- Export Draft as ICS
-
-
-
+ @if (hasPermissionToUpdateActivity) {
+
+
+
+ Edit...
+
+
+ }
+ @if (hasPermissionToCreateActivity) {
+
+
+
+ Clone...
+
+
+ }
+ @if (hasPermissionToExportActivities) {
+
+
+
+ Export Draft as ICS
+
+
+
+ }
|