Browse Source

Add write access

pull/7676/head
Thomas Kaul 1 week ago
parent
commit
a22fb33e7f
  1. 9
      apps/client/src/app/components/access-table/access-table.component.html
  2. 6
      apps/client/src/app/components/access-table/access-table.component.ts
  3. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  4. 1
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  5. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  6. 9
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  7. 23
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  8. 13
      apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts
  9. 13
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  10. 6
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  11. 10
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  12. 13
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts
  13. 4
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  14. 17
      libs/common/src/lib/scopes.spec.ts
  15. 6
      libs/common/src/lib/scopes.ts
  16. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  17. 108
      libs/ui/src/lib/activities-table/activities-table.component.html
  18. 2
      libs/ui/src/lib/activities-table/activities-table.component.ts

9
apps/client/src/app/components/access-table/access-table.component.html

@ -18,17 +18,16 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<div class="align-items-center d-flex">
@if (hasScopeToReadValues(element)) {
@if (hasScopesToWrite(element)) {
<ion-icon class="mr-1" name="create-outline" />
<ng-container i18n>View and manage</ng-container>
} @else if (hasScopeToReadValues(element)) {
<ion-icon class="mr-1" name="lock-open-outline" />
<ng-container i18n>View</ng-container>
} @else {
<ion-icon class="mr-1" name="lock-closed-outline" />
<ng-container i18n>Restricted view</ng-container>
}
@if (hasScopesToWrite(element)) {
<ion-icon class="ml-2 mr-1" name="create-outline" />
<ng-container i18n>Changes</ng-container>
}
</div>
</td>
</ng-container>

6
apps/client/src/app/components/access-table/access-table.component.ts

@ -2,7 +2,7 @@ import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces';
import { publicRoutes } from '@ghostfolio/common/routes/routes';
import {
SCOPES_OF_WRITE_ACCESS,
hasAnyScopeOfWriteAccess,
hasScope,
scopes
} from '@ghostfolio/common/scopes';
@ -110,9 +110,7 @@ export class GfAccessTableComponent {
}
protected hasScopesToWrite({ scopes: scopesOfAccess }: Access) {
return SCOPES_OF_WRITE_ACCESS.some((scope) => {
return hasScope(scopesOfAccess, scope);
});
return hasAnyScopeOfWriteAccess(scopesOfAccess);
}
protected onCopyUrlToClipboard(aId: string) {

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

1
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;
}

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

9
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

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

23
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -6,6 +6,7 @@ import {
SCOPES_OF_READ_RESTRICTED_ACCESS,
SCOPES_OF_WRITE_ACCESS,
Scope,
hasAnyScopeOfWriteAccess,
hasScope,
scopes
} from '@ghostfolio/common/scopes';
@ -186,7 +187,21 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}
private buildScopes(): Scope[] {
switch (this.accessForm.get('accessLevel')?.value as AccessLevel) {
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':
@ -236,11 +251,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}
private getAccessLevel(scopesOfAccess: string[] | undefined): AccessLevel {
if (
SCOPES_OF_WRITE_ACCESS.some((scope) => {
return hasScope(scopesOfAccess, scope);
})
) {
if (hasAnyScopeOfWriteAccess(scopesOfAccess)) {
return 'CREATE_READ_UPDATE_DELETE';
}

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

@ -112,7 +112,7 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
if (
!account ||
!hasPermission(user?.permissions, permissions.updateAccount) ||
this.isReadOnlyMode(user, scopes.accountUpdate)
this.isWriteRestricted(user, scopes.accountUpdate)
) {
this.navigateBack();
@ -141,7 +141,7 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
if (
!hasPermission(user?.permissions, permissions.createAccount) ||
this.isReadOnlyMode(user, scopes.accountCreate)
this.isWriteRestricted(user, scopes.accountCreate)
) {
this.navigateBack();
@ -182,10 +182,9 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
this.dialogRef?.close();
}
private isReadOnlyMode(user: User, requiredScope: Scope) {
private isWriteRestricted(user: User, requiredScope: Scope) {
return (
(!!this.impersonationStorageService.getId() &&
!hasScope(user?.scopes, requiredScope)) ||
!hasScope(user?.scopes, requiredScope) ||
!!user?.settings?.isRestrictedView
);
}
@ -222,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',

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

@ -1,3 +1,4 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { User } from '@ghostfolio/common/interfaces';
@ -48,6 +49,7 @@ export class GfAccountsPageComponent implements OnInit {
protected totalValueInBaseCurrency = 0;
protected user: User;
private hasImpersonationId: boolean;
private isInitialFetch = true;
private readonly deviceType = computed(
@ -59,6 +61,9 @@ export class GfAccountsPageComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
@ -75,6 +80,13 @@ export class GfAccountsPageComponent implements OnInit {
}
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => {
@ -137,6 +149,7 @@ export class GfAccountsPageComponent implements OnInit {
this.totalValueInBaseCurrency = totalValueInBaseCurrency;
if (
!this.hasImpersonationId &&
this.accounts?.length <= 0 &&
this.hasPermissionToCreateAccount &&
this.isInitialFetch

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

@ -77,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;

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

@ -7,15 +7,11 @@
[dataSource]="dataSource"
[deviceType]="deviceType"
[hasActivities]="user?.activitiesCount > 0"
[hasPermissionToCreateActivity]="
hasPermissionToCreateActivity && !hasImpersonationId
"
[hasPermissionToCreateActivity]="hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilterByType]="user?.settings?.isExperimentalFeatures"
[hasPermissionToImportActivities]="
hasPermissionToCreateActivity && !hasImpersonationId
"
[hasPermissionToImportActivities]="hasPermissionToImportActivities"
[hasPermissionToUpdateActivity]="hasPermissionToUpdateActivity"
[locale]="user?.settings?.locale"
[pageIndex]="pageIndex"
@ -43,7 +39,7 @@
</div>
</div>
@if (hasPermissionToCreateActivity && !user.settings.isRestrictedView) {
@if (hasPermissionToCreateActivity) {
<gf-fab
[routerLink]="
internalRoutes.portfolio.subRoutes.activities.subRoutes.create

13
apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.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 { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { Activity, User } from '@ghostfolio/common/interfaces';
@ -47,9 +46,6 @@ export class GfActivityDialogHostComponent implements OnDestroy, 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);
@ -95,7 +91,7 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
if (
!activity ||
!hasPermission(user?.permissions, permissions.updateActivity) ||
this.isReadOnlyMode(user, scopes.activityUpdate)
this.isWriteRestricted(user, scopes.activityUpdate)
) {
this.navigateBack();
@ -116,7 +112,7 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
// Cloning creates a new activity as well
if (
!hasPermission(user?.permissions, permissions.createActivity) ||
this.isReadOnlyMode(user, scopes.activityCreate)
this.isWriteRestricted(user, scopes.activityCreate)
) {
this.navigateBack();
@ -157,10 +153,9 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
this.dialogRef?.close();
}
private isReadOnlyMode(user: User, requiredScope: Scope) {
private isWriteRestricted(user: User, requiredScope: Scope) {
return (
(!!this.impersonationStorageService.getId() &&
!hasScope(user?.scopes, requiredScope)) ||
!hasScope(user?.scopes, requiredScope) ||
!!user?.settings?.isRestrictedView
);
}

4
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -626,6 +626,10 @@ export class GfAllocationsPageComponent implements OnInit {
!this.impersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView,
hasPermissionToUpdateActivity:
!this.impersonationId &&
hasPermission(this.user?.permissions, permissions.updateActivity) &&
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId
},
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',

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

@ -5,6 +5,7 @@ import {
getScopesOfAccess,
getScopesOfOwnAccess,
getScopesOfUnrestrictedImpersonation,
hasAnyScopeOfWriteAccess,
hasScope,
scopes
} from '@ghostfolio/common/scopes';
@ -189,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(

6
libs/common/src/lib/scopes.ts

@ -97,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);
}

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

@ -1,4 +1,4 @@
@if (showActions()) {
@if (showActions() && hasPermissionToUpdateAccount()) {
<div class="d-flex justify-content-end">
<button
class="align-items-center d-flex"

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

@ -19,17 +19,19 @@
}
</div>
@if (hasPermissionToImportActivities) {
@if (hasPermissionToImportActivities || hasPermissionToDeleteActivity) {
<div class="d-flex">
<button
class="align-items-center d-flex"
mat-stroked-button
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline" />
<span><ng-container i18n>Import Activities</ng-container>...</span>
</button>
@if (hasPermissionToExportActivities) {
@if (hasPermissionToImportActivities) {
<button
class="align-items-center d-flex"
mat-stroked-button
(click)="onImport()"
>
<ion-icon class="mr-2" name="cloud-upload-outline" />
<span><ng-container i18n>Import Activities</ng-container>...</span>
</button>
}
@if (hasPermissionToExportActivities || hasPermissionToDeleteActivity) {
<button
class="mx-1 no-min-width px-2"
mat-stroked-button
@ -44,16 +46,18 @@
class="no-max-width"
xPosition="before"
>
<button
mat-menu-item
[disabled]="dataSource()?.data.length === 0"
(click)="onImportDividends()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="color-wand-outline" />
<span><ng-container i18n>Import Dividends</ng-container>...</span>
</span>
</button>
@if (hasPermissionToImportActivities) {
<button
mat-menu-item
[disabled]="dataSource()?.data.length === 0"
(click)="onImportDividends()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="color-wand-outline" />
<span><ng-container i18n>Import Dividends</ng-container>...</span>
</span>
</button>
}
@if (hasPermissionToExportActivities) {
<button
class="align-items-center d-flex"
@ -87,25 +91,31 @@
</span>
</button>
}
<hr class="m-0" />
<button
class="align-items-center d-flex"
mat-menu-item
[disabled]="!canDeleteActivities()"
(click)="onDeleteActivities()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
@if (canDeleteActivities()) {
<span i18n
>Delete {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Delete Activities</span>
}
</span>
</button>
@if (hasPermissionToDeleteActivity) {
@if (
hasPermissionToExportActivities || hasPermissionToImportActivities
) {
<hr class="m-0" />
}
<button
class="align-items-center d-flex"
mat-menu-item
[disabled]="!canDeleteActivities()"
(click)="onDeleteActivities()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
@if (canDeleteActivities()) {
<span i18n
>Delete {{ totalItems > 1 ? totalItems : '' }}
{totalItems, plural, =1 {Activity} other {Activities}}</span
>
} @else {
<span i18n>Delete Activities</span>
}
</span>
</button>
}
</mat-menu>
</div>
}
@ -504,15 +514,17 @@
</span>
</a>
}
<a
mat-menu-item
[routerLink]="activityDialogRouterLinks().get(element.id)?.clone"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span><ng-container i18n>Clone</ng-container>...</span>
</span>
</a>
@if (hasPermissionToCreateActivity) {
<a
mat-menu-item
[routerLink]="activityDialogRouterLinks().get(element.id)?.clone"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span><ng-container i18n>Clone</ng-container>...</span>
</span>
</a>
}
<button
mat-menu-item
[disabled]="!isDraftActivity(element)"

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

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

Loading…
Cancel
Save