Browse Source

Task/add write access to create or update access dialog (#7676)

* Add write access

* Update changelog
pull/7684/head
Thomas Kaul 7 days ago
committed by GitHub
parent
commit
67dd8fbf0f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 5
      apps/client/src/app/components/access-table/access-table.component.html
  3. 10
      apps/client/src/app/components/access-table/access-table.component.ts
  4. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  5. 1
      apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts
  6. 4
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  7. 22
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  8. 6
      apps/client/src/app/components/home-holdings/home-holdings.html
  9. 6
      apps/client/src/app/components/home-overview/home-overview.component.ts
  10. 28
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  11. 2
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  12. 69
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  13. 12
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  14. 3
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
  15. 13
      apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts
  16. 30
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  17. 11
      apps/client/src/app/pages/accounts/accounts-page.html
  18. 21
      apps/client/src/app/pages/portfolio/activities/activities-page.component.ts
  19. 13
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  20. 13
      apps/client/src/app/pages/portfolio/activities/activity-dialog-host/activity-dialog-host.component.ts
  21. 4
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  22. 40
      libs/common/src/lib/scopes.spec.ts
  23. 11
      libs/common/src/lib/scopes.ts
  24. 46
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  25. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  26. 158
      libs/ui/src/lib/activities-table/activities-table.component.html
  27. 2
      libs/ui/src/lib/activities-table/activities-table.component.ts

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added the permission to manage data to the grant access dialog and to the access table to share the portfolio (experimental)
### Changed ### Changed
- Extended the account selector options to include the platform and currency - Extended the account selector options to include the platform and currency

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

@ -18,7 +18,10 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Permission</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<div class="align-items-center d-flex"> <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" /> <ion-icon class="mr-1" name="lock-open-outline" />
<ng-container i18n>View</ng-container> <ng-container i18n>View</ng-container>
} @else { } @else {

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

@ -1,7 +1,11 @@
import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; 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 { NotificationService } from '@ghostfolio/ui/notifications';
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard';
@ -105,6 +109,10 @@ export class GfAccessTableComponent {
return hasScope(scopesOfAccess, scopes.portfolioReadValues); return hasScope(scopesOfAccess, scopes.portfolioReadValues);
} }
protected hasScopesToWrite({ scopes: scopesOfAccess }: Access) {
return hasAnyScopeOfWriteAccess(scopesOfAccess);
}
protected onCopyUrlToClipboard(aId: string) { protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId)); this.clipboard.copy(this.getPublicUrl(aId));

4
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -148,11 +148,13 @@
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasActivities]="true"
[hasPermissionToCreateActivity]="data.hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="false" [hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]="!showValuesInPercentage()" [hasPermissionToExportActivities]="!showValuesInPercentage()"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[hasPermissionToUpdateActivity]="data.hasPermissionToUpdateActivity"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"

1
apps/client/src/app/components/account-detail-dialog/interfaces/interfaces.ts

@ -2,6 +2,7 @@ export interface AccountDetailDialogParams {
accountId: string; accountId: string;
deviceType: string; deviceType: string;
hasPermissionToCreateActivity: boolean; hasPermissionToCreateActivity: boolean;
hasPermissionToUpdateActivity: boolean;
impersonationId: string | null; impersonationId: string | null;
} }

4
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -379,13 +379,15 @@
[baseCurrency]="data.baseCurrency" [baseCurrency]="data.baseCurrency"
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasActivities]="true"
[hasPermissionToCreateActivity]="data.hasPermissionToCreateActivity"
[hasPermissionToDeleteActivity]="false" [hasPermissionToDeleteActivity]="false"
[hasPermissionToExportActivities]=" [hasPermissionToExportActivities]="
!data.impersonationId && !user?.settings?.isRestrictedView !data.impersonationId && !user?.settings?.isRestrictedView
" "
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[hasPermissionToUpdateActivity]="data.hasPermissionToUpdateActivity"
[locale]="data.locale" [locale]="data.locale"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"

22
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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -7,6 +6,7 @@ import {
} from '@ghostfolio/common/interfaces'; } 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 { hasScope, scopes } from '@ghostfolio/common/scopes';
import { import {
HoldingType, HoldingType,
HoldingsViewMode, HoldingsViewMode,
@ -51,7 +51,6 @@ export class GfHomeHoldingsComponent implements OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE'; public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
protected deviceType: string; protected deviceType: string;
protected hasImpersonationId: boolean;
protected hasPermissionToAccessHoldingsChart: boolean; protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean; protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined; protected holdings: PortfolioPosition[] | undefined;
@ -83,9 +82,6 @@ export class GfHomeHoldingsComponent 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 router = inject(Router); private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
@ -96,15 +92,6 @@ export class GfHomeHoldingsComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((state) => { .subscribe((state) => {
@ -116,10 +103,9 @@ export class GfHomeHoldingsComponent implements OnInit {
permissions.accessHoldingsChart permissions.accessHoldingsChart
); );
this.hasPermissionToCreateActivity = hasPermission( this.hasPermissionToCreateActivity =
this.user.permissions, hasPermission(this.user.permissions, permissions.createActivity) &&
permissions.createActivity hasScope(this.user.scopes, scopes.activityCreate);
);
this.initialize(); this.initialize();
} }

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

@ -41,11 +41,7 @@
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
(holdingClicked)="onHoldingClicked($event)" (holdingClicked)="onHoldingClicked($event)"
/> />
@if ( @if (hasPermissionToCreateActivity && holdings?.length > 0) {
!hasImpersonationId &&
hasPermissionToCreateActivity &&
holdings?.length > 0
) {
<div class="text-center"> <div class="text-center">
<a <a
class="mt-3" class="mt-3"

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

@ -15,6 +15,7 @@ import {
} from '@ghostfolio/common/interfaces'; } 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 { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -66,7 +67,10 @@ export class GfHomeOverviewComponent implements OnInit {
); );
protected readonly hasPermissionToCreateActivity = computed(() => { protected readonly hasPermissionToCreateActivity = computed(() => {
return hasPermission(this.user()?.permissions, permissions.createActivity); return (
hasPermission(this.user()?.permissions, permissions.createActivity) &&
hasScope(this.user()?.scopes, scopes.activityCreate)
);
}); });
protected readonly showDetails = computed(() => { protected readonly showDetails = computed(() => {

28
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 { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { import {
@ -7,6 +6,7 @@ import {
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
@ -44,7 +44,6 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
templateUrl: './home-watchlist.html' templateUrl: './home-watchlist.html'
}) })
export class GfHomeWatchlistComponent implements OnInit { export class GfHomeWatchlistComponent implements OnInit {
protected hasImpersonationId: boolean;
protected hasPermissionToCreateWatchlistItem: boolean; protected hasPermissionToCreateWatchlistItem: boolean;
protected hasPermissionToDeleteWatchlistItem: boolean; protected hasPermissionToDeleteWatchlistItem: boolean;
protected user: User; protected user: User;
@ -59,21 +58,11 @@ export class GfHomeWatchlistComponent 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 route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
public constructor() { public constructor() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => {
this.hasImpersonationId = !!impersonationId;
});
this.route.queryParams this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => { .subscribe((params) => {
@ -89,17 +78,15 @@ export class GfHomeWatchlistComponent implements OnInit {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateWatchlistItem = this.hasPermissionToCreateWatchlistItem =
!this.hasImpersonationId &&
hasPermission( hasPermission(
this.user.permissions, this.user.permissions,
permissions.createWatchlistItem permissions.createWatchlistItem
); ) && hasScope(this.user.scopes, scopes.watchlistCreate);
this.hasPermissionToDeleteWatchlistItem = this.hasPermissionToDeleteWatchlistItem =
!this.hasImpersonationId &&
hasPermission( hasPermission(
this.user.permissions, this.user.permissions,
permissions.deleteWatchlistItem permissions.deleteWatchlistItem
); ) && hasScope(this.user.scopes, scopes.watchlistDelete);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
@ -149,6 +136,15 @@ export class GfHomeWatchlistComponent implements OnInit {
.subscribe((user) => { .subscribe((user) => {
this.user = 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< const dialogRef = this.dialog.open<
GfCreateWatchlistItemDialogComponent, GfCreateWatchlistItemDialogComponent,
CreateWatchlistItemDialogParams CreateWatchlistItemDialogParams

2
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -21,6 +21,6 @@
</div> </div>
</div> </div>
</div> </div>
@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) { @if (hasPermissionToCreateWatchlistItem) {
<gf-fab [queryParams]="{ createWatchlistItemDialog: true }" /> <gf-fab [queryParams]="{ createWatchlistItemDialog: true }" />
} }

69
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 { import {
SCOPES_OF_READ_ACCESS, SCOPES_OF_READ_ACCESS,
SCOPES_OF_READ_RESTRICTED_ACCESS, SCOPES_OF_READ_RESTRICTED_ACCESS,
SCOPES_OF_WRITE_ACCESS,
Scope,
hasAnyScopeOfWriteAccess,
hasScope, hasScope,
scopes scopes
} from '@ghostfolio/common/scopes'; } from '@ghostfolio/common/scopes';
@ -49,7 +52,10 @@ import { MatSelectModule } from '@angular/material/select';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { EMPTY, catchError } from 'rxjs'; import { EMPTY, catchError } from 'rxjs';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; import {
AccessLevel,
CreateOrUpdateAccessDialogParams
} from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -105,21 +111,22 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
); );
} }
public get canGrantWriteAccess() {
return this.hasExperimentalFeatures;
}
public ngOnInit() { public ngOnInit() {
const access = this.data?.access; const access = this.data?.access;
const isPublic = access?.type === 'PUBLIC'; const isPublic = access?.type === 'PUBLIC';
this.accessForm = this.formBuilder.group({ this.accessForm = this.formBuilder.group({
accessLevel: this.getAccessLevel(access?.scopes),
alias: [access?.alias ?? ''], alias: [access?.alias ?? ''],
filters: [null], filters: [null],
granteeUserId: [ granteeUserId: [
access?.grantee ?? null, access?.grantee ?? null,
isPublic ? null : Validators.required isPublic ? null : Validators.required
], ],
hasScopeToReadValues: hasScope(
access?.scopes,
scopes.portfolioReadValues
),
type: [ type: [
{ disabled: this.mode === 'update', value: access?.type ?? 'PRIVATE' }, { disabled: this.mode === 'update', value: access?.type ?? 'PRIVATE' },
Validators.required Validators.required
@ -145,10 +152,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
.subscribe((accessType) => { .subscribe((accessType) => {
const granteeUserIdControl = this.accessForm.get('granteeUserId'); const granteeUserIdControl = this.accessForm.get('granteeUserId');
const hasScopeToReadValuesControl = this.accessForm.get(
'hasScopeToReadValues'
);
if (accessType === 'PRIVATE') { if (accessType === 'PRIVATE') {
granteeUserIdControl?.setValidators(Validators.required); granteeUserIdControl?.setValidators(Validators.required);
this.accessForm.get('filters')?.setValue(null); this.accessForm.get('filters')?.setValue(null);
@ -156,8 +159,9 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
granteeUserIdControl?.clearValidators(); granteeUserIdControl?.clearValidators();
granteeUserIdControl?.setValue(null); granteeUserIdControl?.setValue(null);
// A public access never exposes the monetary values // A public access never exposes the monetary values and never
hasScopeToReadValuesControl?.setValue(false); // changes data
this.accessForm.get('accessLevel')?.setValue('READ_RESTRICTED');
} }
granteeUserIdControl?.updateValueAndValidity(); granteeUserIdControl?.updateValueAndValidity();
@ -186,16 +190,29 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
); );
} }
/** private buildScopes(): Scope[] {
* The dialog offers the read access only. The write scopes are not granted const accessLevel = this.accessForm.get('accessLevel')
* here yet. ?.value as AccessLevel;
*/
private buildScopes() { const scopesOfAccess = this.data.access?.scopes ?? [];
return [
...(this.accessForm.get('hasScopeToReadValues')?.value if (
? SCOPES_OF_READ_ACCESS scopesOfAccess.length > 0 &&
: SCOPES_OF_READ_RESTRICTED_ACCESS) 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() { 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() { private loadHoldings() {
this.dataService this.dataService
.fetchPortfolioHoldings() .fetchPortfolioHoldings()

12
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html

@ -36,10 +36,16 @@
<div> <div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Permission</mat-label> <mat-label i18n>Permission</mat-label>
<mat-select formControlName="hasScopeToReadValues"> <mat-select formControlName="accessLevel">
<mat-option i18n [value]="false">Restricted view</mat-option> <mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option>
@if (accessForm.get('type')?.value === 'PRIVATE') { @if (accessForm.get('type')?.value === 'PRIVATE') {
<mat-option i18n [value]="true">View</mat-option> <mat-option i18n value="READ">View</mat-option>
<mat-option
i18n
value="CREATE_READ_UPDATE_DELETE"
[disabled]="!canGrantWriteAccess"
>View and manage</mat-option
>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>

3
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 { export interface CreateOrUpdateAccessDialogParams {
access?: Access; access?: Access;
} }
export type AccessLevel =
'CREATE_READ_UPDATE_DELETE' | 'READ' | 'READ_RESTRICTED';

13
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.isWriteRestricted(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.isWriteRestricted(user, scopes.accountCreate)
) { ) {
this.navigateBack(); this.navigateBack();
@ -181,9 +182,9 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
this.dialogRef?.close(); this.dialogRef?.close();
} }
private isReadOnlyMode(user: User) { private isWriteRestricted(user: User, requiredScope: Scope) {
return ( return (
!!this.impersonationStorageService.getId() || !hasScope(user?.scopes, requiredScope) ||
!!user?.settings?.isRestrictedView !!user?.settings?.isRestrictedView
); );
} }
@ -220,6 +221,10 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
hasPermissionToCreateActivity: hasPermissionToCreateActivity:
!impersonationId && !impersonationId &&
hasPermission(user?.permissions, permissions.createActivity) && hasPermission(user?.permissions, permissions.createActivity) &&
!user?.settings?.isRestrictedView,
hasPermissionToUpdateActivity:
!impersonationId &&
hasPermission(user?.permissions, permissions.updateActivity) &&
!user?.settings?.isRestrictedView !user?.settings?.isRestrictedView
}, },
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', height: this.deviceType() === 'mobile' ? '98vh' : '80vh',

30
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 { 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 { hasScope, scopes } from '@ghostfolio/common/scopes';
import { AccountWithValue } from '@ghostfolio/common/types'; import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table'; import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
@ -41,13 +42,14 @@ 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;
protected user: User; protected user: User;
private hasImpersonationId: boolean;
private isInitialFetch = true; private isInitialFetch = true;
private readonly deviceType = computed( private readonly deviceType = computed(
@ -77,16 +79,12 @@ export class GfAccountsPageComponent implements OnInit {
}); });
} }
protected get hasImpersonationId() {
return !!this.impersonationId;
}
public ngOnInit() { public ngOnInit() {
this.impersonationStorageService this.impersonationStorageService
.onChangeHasImpersonation() .onChangeHasImpersonation()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((impersonationId) => { .subscribe((impersonationId) => {
this.impersonationId = impersonationId; this.hasImpersonationId = !!impersonationId;
}); });
this.userService.stateChanged this.userService.stateChanged
@ -95,14 +93,17 @@ export class GfAccountsPageComponent implements OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateAccount = hasPermission( this.hasPermissionToCreateAccount =
this.user.permissions, hasPermission(this.user.permissions, permissions.createAccount) &&
permissions.createAccount hasScope(this.user.scopes, scopes.accountCreate);
);
this.hasPermissionToUpdateAccount = hasPermission( this.hasPermissionToDeleteAccount =
this.user.permissions, hasPermission(this.user.permissions, permissions.deleteAccount) &&
permissions.updateAccount hasScope(this.user.scopes, scopes.accountDelete);
);
this.hasPermissionToUpdateAccount =
hasPermission(this.user.permissions, permissions.updateAccount) &&
hasScope(this.user.scopes, scopes.accountUpdate);
this.fetchAccounts(); this.fetchAccounts();
} }
@ -148,6 +149,7 @@ export class GfAccountsPageComponent implements OnInit {
this.totalValueInBaseCurrency = totalValueInBaseCurrency; this.totalValueInBaseCurrency = totalValueInBaseCurrency;
if ( if (
!this.hasImpersonationId &&
this.accounts?.length <= 0 && this.accounts?.length <= 0 &&
this.hasPermissionToCreateAccount && this.hasPermissionToCreateAccount &&
this.isInitialFetch this.isInitialFetch

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

@ -6,10 +6,11 @@
[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]="
!hasImpersonationId && (hasPermissionToDeleteAccount || hasPermissionToUpdateAccount) &&
hasPermissionToUpdateAccount &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
" "
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency" [totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
@ -20,11 +21,7 @@
</div> </div>
</div> </div>
@if ( @if (hasPermissionToCreateAccount && !user.settings.isRestrictedView) {
!hasImpersonationId &&
hasPermissionToCreateAccount &&
!user.settings.isRestrictedView
) {
<gf-fab <gf-fab
[routerLink]="internalRoutes.accounts.subRoutes.create.routerLink" [routerLink]="internalRoutes.accounts.subRoutes.create.routerLink"
/> />

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

@ -10,6 +10,7 @@ import {
} from '@ghostfolio/common/interfaces'; } 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 { hasScope, scopes } from '@ghostfolio/common/scopes';
import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table';
import { GfFabComponent } from '@ghostfolio/ui/fab'; import { GfFabComponent } from '@ghostfolio/ui/fab';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
@ -53,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;
@ -75,6 +77,12 @@ export class GfActivitiesPageComponent implements OnInit {
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly userService = inject(UserService); 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() { public ngOnInit() {
this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType; this.deviceType = this.deviceDetectorService.getDeviceInfo().deviceType;
@ -301,6 +309,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
) { ) {
@ -330,12 +339,18 @@ export class GfActivitiesPageComponent implements OnInit {
this.user = aUser; this.user = aUser;
this.hasPermissionToCreateActivity = this.hasPermissionToCreateActivity =
!this.hasImpersonationId &&
hasPermission(this.user.permissions, permissions.createActivity) && hasPermission(this.user.permissions, permissions.createActivity) &&
hasScope(this.user.scopes, scopes.activityCreate) &&
!this.user.settings?.isRestrictedView; !this.user.settings?.isRestrictedView;
this.hasPermissionToDeleteActivity = 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;
} }
} }

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

@ -11,12 +11,15 @@
[hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity" [hasPermissionToDeleteActivity]="hasPermissionToDeleteActivity"
[hasPermissionToExportActivities]="!hasImpersonationId" [hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilterByType]="user?.settings?.isExperimentalFeatures" [hasPermissionToFilterByType]="user?.settings?.isExperimentalFeatures"
[hasPermissionToImportActivities]="hasPermissionToImportActivities"
[hasPermissionToUpdateActivity]="hasPermissionToUpdateActivity"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[pageIndex]="pageIndex" [pageIndex]="pageIndex"
[pageSize]="pageSize" [pageSize]="pageSize"
[showActions]=" [showActions]="
!hasImpersonationId && (hasPermissionToCreateActivity ||
hasPermissionToDeleteActivity && hasPermissionToDeleteActivity ||
hasPermissionToUpdateActivity) &&
!user.settings.isRestrictedView !user.settings.isRestrictedView
" "
[sortColumn]="sortColumn" [sortColumn]="sortColumn"
@ -36,11 +39,7 @@
</div> </div>
</div> </div>
@if ( @if (hasPermissionToCreateActivity) {
!hasImpersonationId &&
hasPermissionToCreateActivity &&
!user.settings.isRestrictedView
) {
<gf-fab <gf-fab
[routerLink]=" [routerLink]="
internalRoutes.portfolio.subRoutes.activities.subRoutes.create internalRoutes.portfolio.subRoutes.activities.subRoutes.create

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

@ -1,9 +1,9 @@
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 { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; 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 {
@ -46,9 +46,6 @@ export class GfActivityDialogHostComponent implements OnDestroy, 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 route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
@ -94,7 +91,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.isWriteRestricted(user, scopes.activityUpdate)
) { ) {
this.navigateBack(); this.navigateBack();
@ -115,7 +112,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.isWriteRestricted(user, scopes.activityCreate)
) { ) {
this.navigateBack(); this.navigateBack();
@ -156,9 +153,9 @@ export class GfActivityDialogHostComponent implements OnDestroy, OnInit {
this.dialogRef?.close(); this.dialogRef?.close();
} }
private isReadOnlyMode(user: User) { private isWriteRestricted(user: User, requiredScope: Scope) {
return ( return (
!!this.impersonationStorageService.getId() || !hasScope(user?.scopes, requiredScope) ||
!!user?.settings?.isRestrictedView !!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 && !this.impersonationId &&
hasPermission(this.user?.permissions, permissions.createActivity) && hasPermission(this.user?.permissions, permissions.createActivity) &&
!this.user?.settings?.isRestrictedView, !this.user?.settings?.isRestrictedView,
hasPermissionToUpdateActivity:
!this.impersonationId &&
hasPermission(this.user?.permissions, permissions.updateActivity) &&
!this.user?.settings?.isRestrictedView,
impersonationId: this.impersonationId impersonationId: this.impersonationId
}, },
height: this.deviceType() === 'mobile' ? '98vh' : '80vh', height: this.deviceType() === 'mobile' ? '98vh' : '80vh',

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

@ -5,6 +5,7 @@ import {
getScopesOfAccess, getScopesOfAccess,
getScopesOfOwnAccess, getScopesOfOwnAccess,
getScopesOfUnrestrictedImpersonation, getScopesOfUnrestrictedImpersonation,
hasAnyScopeOfWriteAccess,
hasScope, hasScope,
scopes scopes
} from '@ghostfolio/common/scopes'; } from '@ghostfolio/common/scopes';
@ -82,18 +83,25 @@ describe('Scopes', () => {
).toEqual([]); ).toEqual([]);
}); });
// TODO: Remove this expectation once the dialog allows to configure the it('Gives the write scopes', () => {
// write scopes
it('Gives no write scope', () => {
const scopesOfAccess = getScopesOfAccess({ const scopesOfAccess = getScopesOfAccess({
granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d', granteeUserId: 'ffb08949-2f8a-4b6e-88fd-0f1e6b6b5f5d',
scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS] scopes: [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS]
}); });
for (const scope of 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', () => { describe('Get scopes of public access', () => {
@ -129,6 +137,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', () => {
@ -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', () => { describe('Has scope', () => {
it('Present scope', () => { it('Present scope', () => {
expect(hasScope([scopes.portfolioRead], scopes.portfolioRead)).toEqual( expect(hasScope([scopes.portfolioRead], scopes.portfolioRead)).toEqual(

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

@ -67,8 +67,9 @@ export function getScopesOfAccess({
const scopesToEvaluate = scopesOfAccess ?? []; const scopesToEvaluate = scopesOfAccess ?? [];
if (granteeUserId) { if (granteeUserId) {
// TODO: Permit the write scopes once the dialog allows to configure them // An unknown scope is dropped, so that a scope which has been removed from
return SCOPES_OF_READ_ACCESS.filter((scope) => { // the vocabulary cannot stay effective
return Object.values(scopes).filter((scope) => {
return scopesToEvaluate.includes(scope); return scopesToEvaluate.includes(scope);
}); });
} }
@ -96,6 +97,12 @@ export function getScopesOfUnrestrictedImpersonation(): string[] {
return [...SCOPES_OF_READ_RESTRICTED_ACCESS]; 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) { export function hasScope(aScopes: string[] = [], aScope: Scope) {
return aScopes.includes(aScope); return aScopes.includes(aScope);
} }

46
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"> <div class="d-flex justify-content-end">
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
@ -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);

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

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

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: boolean;
@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