From 5232e8f3669aa9db8967e63858a5cc408bd5118e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:11:29 +0200 Subject: [PATCH] Task/extract access level icon to reusable component (#7692) * Extract access level icon to reusable component * Update changelog --- CHANGELOG.md | 2 + .../access-table/access-table.component.html | 13 +----- .../access-table/access-table.component.ts | 22 +++------ ...reate-or-update-access-dialog.component.ts | 45 ++++++------------- .../create-or-update-access-dialog.html | 15 +++++-- .../interfaces/interfaces.ts | 3 -- libs/common/src/lib/scopes.ts | 29 ++++++++++++ .../common/src/lib/types/access-level.type.ts | 2 + libs/common/src/lib/types/index.ts | 2 + .../access-level-icon.component.html | 16 +++++++ .../access-level-icon.component.stories.ts | 42 +++++++++++++++++ .../access-level-icon.component.ts | 24 ++++++++++ libs/ui/src/lib/access-level-icon/index.ts | 1 + 13 files changed, 148 insertions(+), 68 deletions(-) create mode 100644 libs/common/src/lib/types/access-level.type.ts create mode 100644 libs/ui/src/lib/access-level-icon/access-level-icon.component.html create mode 100644 libs/ui/src/lib/access-level-icon/access-level-icon.component.stories.ts create mode 100644 libs/ui/src/lib/access-level-icon/access-level-icon.component.ts create mode 100644 libs/ui/src/lib/access-level-icon/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9756be7e4..54b4eacd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the permission selector with icons in the create or update access dialog +- Extracted the access level icon to a reusable component - Disabled the telemetry in the _Storybook_ setup - Upgraded the `Node.js` engine from version `>=22.18.0` to `>=22.22.3` (`package.json`) 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 38122c39c..4edaed42d 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 @@ -17,18 +17,7 @@ Permission - - @if (hasScopesToWrite(element)) { - - View and manage - } @else if (hasScopeToReadValues(element)) { - - View - } @else { - - Restricted view - } - + 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 58a9e8034..900184110 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,11 +1,8 @@ import { ConfirmationDialogType } from '@ghostfolio/common/enums'; import { Access, User } from '@ghostfolio/common/interfaces'; import { publicRoutes } from '@ghostfolio/common/routes/routes'; -import { - hasAnyScopeOfWriteAccess, - hasScope, - scopes -} from '@ghostfolio/common/scopes'; +import { getAccessLevel } from '@ghostfolio/common/scopes'; +import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'; @@ -31,8 +28,6 @@ import { createOutline, ellipsisHorizontal, linkOutline, - lockClosedOutline, - lockOpenOutline, removeCircleOutline } from 'ionicons/icons'; import ms from 'ms'; @@ -42,6 +37,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ClipboardModule, + GfAccessLevelIconComponent, IonIcon, MatButtonModule, MatMenuModule, @@ -75,6 +71,8 @@ export class GfAccessTableComponent { return columns; }); + protected readonly getAccessLevel = getAccessLevel; + protected readonly isLoading = computed(() => { return !this.accesses(); }); @@ -89,8 +87,6 @@ export class GfAccessTableComponent { createOutline, ellipsisHorizontal, linkOutline, - lockClosedOutline, - lockOpenOutline, removeCircleOutline }); @@ -105,14 +101,6 @@ export class GfAccessTableComponent { return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; } - protected hasScopeToReadValues({ scopes: scopesOfAccess }: Access) { - 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/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 40c4fe703..2f61e850a 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 @@ -2,16 +2,15 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { - SCOPES_OF_READ_ACCESS, - SCOPES_OF_READ_RESTRICTED_ACCESS, - SCOPES_OF_WRITE_ACCESS, Scope, - hasAnyScopeOfWriteAccess, + getAccessLevel, + getScopesOfAccessLevel, hasScope, scopes } from '@ghostfolio/common/scopes'; -import { AccountWithPlatform } from '@ghostfolio/common/types'; +import { AccessLevel, AccountWithPlatform } from '@ghostfolio/common/types'; import { validateObjectForForm } from '@ghostfolio/common/utils'; +import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon'; import { NotificationService } from '@ghostfolio/ui/notifications'; import { GfPortfolioFilterFormComponent, @@ -52,16 +51,14 @@ import { MatSelectModule } from '@angular/material/select'; import { StatusCodes } from 'http-status-codes'; import { EMPTY, catchError } from 'rxjs'; -import { - AccessLevel, - CreateOrUpdateAccessDialogParams -} from './interfaces/interfaces'; +import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, host: { class: 'h-100' }, imports: [ FormsModule, + GfAccessLevelIconComponent, GfPortfolioFilterFormComponent, MatButtonModule, MatDialogModule, @@ -120,7 +117,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { const isPublic = access?.type === 'PUBLIC'; this.accessForm = this.formBuilder.group({ - accessLevel: this.getAccessLevel(access?.scopes), + accessLevel: getAccessLevel(access?.scopes), alias: [access?.alias ?? ''], filters: [null], granteeUserId: [ @@ -172,6 +169,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.loadHoldings(); } + protected get accessLevel(): AccessLevel { + return this.accessForm?.get('accessLevel')?.value as AccessLevel; + } + protected onCancel() { this.dialogRef.close(); } @@ -191,28 +192,18 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { } 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) + this.accessLevel === 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]; - } + return getScopesOfAccessLevel(this.accessLevel); } private async createAccess() { @@ -254,16 +245,6 @@ 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 545dde228..a01b21138 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 @@ -37,15 +37,22 @@ Permission - Restricted view + + + + + + @if (accessForm.get('type')?.value === 'PRIVATE') { - 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 ca7cee25a..8d1ac0ba9 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,6 +3,3 @@ import { Access } from '@ghostfolio/common/interfaces'; export interface CreateOrUpdateAccessDialogParams { access?: Access; } - -export type AccessLevel = - 'CREATE_READ_UPDATE_DELETE' | 'READ' | 'READ_RESTRICTED'; diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts index fa05f4290..ea3c342fd 100644 --- a/libs/common/src/lib/scopes.ts +++ b/libs/common/src/lib/scopes.ts @@ -1,3 +1,5 @@ +import { AccessLevel } from '@ghostfolio/common/types'; + /** * Scopes describe what a grantee may do on behalf of the granting user. They * are a separate axis from the permissions, which describe the capabilities of @@ -57,6 +59,19 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] = return scope !== scopes.portfolioReadValues; }); +/** + * Access level which the scopes of an access grant + */ +export function getAccessLevel(aScopes: string[] = []): AccessLevel { + if (hasAnyScopeOfWriteAccess(aScopes)) { + return 'CREATE_READ_UPDATE_DELETE'; + } + + return hasScope(aScopes, scopes.portfolioReadValues) + ? 'READ' + : 'READ_RESTRICTED'; +} + export function getScopesOfAccess({ granteeUserId, scopes: scopesOfAccess @@ -81,6 +96,20 @@ export function getScopesOfAccess({ }); } +/** + * Scopes which an access level grants + */ +export function getScopesOfAccessLevel(aAccessLevel: AccessLevel): Scope[] { + switch (aAccessLevel) { + 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]; + } +} + /** * Scopes of a user acting on their own data, which is unrestricted. The * permissions of the role are evaluated separately. diff --git a/libs/common/src/lib/types/access-level.type.ts b/libs/common/src/lib/types/access-level.type.ts new file mode 100644 index 000000000..33f0f11a2 --- /dev/null +++ b/libs/common/src/lib/types/access-level.type.ts @@ -0,0 +1,2 @@ +export type AccessLevel = + 'CREATE_READ_UPDATE_DELETE' | 'READ' | 'READ_RESTRICTED'; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index c31d9079f..7dea25ddc 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -1,3 +1,4 @@ +import type { AccessLevel } from './access-level.type'; import type { AccessType } from './access-type.type'; import type { AccessWithGranteeUser } from './access-with-grantee-user.type'; import type { AccountWithBalance } from './account-with-balance.type'; @@ -29,6 +30,7 @@ import type { UserWithSettings } from './user-with-settings.type'; import type { ViewMode } from './view-mode.type'; export type { + AccessLevel, AccessType, AccessWithGranteeUser, AccountWithBalance, diff --git a/libs/ui/src/lib/access-level-icon/access-level-icon.component.html b/libs/ui/src/lib/access-level-icon/access-level-icon.component.html new file mode 100644 index 000000000..c610d4b57 --- /dev/null +++ b/libs/ui/src/lib/access-level-icon/access-level-icon.component.html @@ -0,0 +1,16 @@ + + @switch (accessLevel()) { + @case ('CREATE_READ_UPDATE_DELETE') { + + View and manage + } + @case ('READ') { + + View + } + @case ('READ_RESTRICTED') { + + Restricted view + } + } + diff --git a/libs/ui/src/lib/access-level-icon/access-level-icon.component.stories.ts b/libs/ui/src/lib/access-level-icon/access-level-icon.component.stories.ts new file mode 100644 index 000000000..a230f60de --- /dev/null +++ b/libs/ui/src/lib/access-level-icon/access-level-icon.component.stories.ts @@ -0,0 +1,42 @@ +import { CommonModule } from '@angular/common'; +import { IonIcon } from '@ionic/angular/standalone'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; + +import { GfAccessLevelIconComponent } from './access-level-icon.component'; + +export default { + title: 'Access Level Icon', + component: GfAccessLevelIconComponent, + decorators: [ + moduleMetadata({ + imports: [CommonModule, IonIcon] + }) + ], + argTypes: { + accessLevel: { + control: 'select', + options: ['CREATE_READ_UPDATE_DELETE', 'READ', 'READ_RESTRICTED'] + } + } +} as Meta; + +type Story = StoryObj; + +export const RestrictedView: Story = { + args: { + accessLevel: 'READ_RESTRICTED' + } +}; + +export const View: Story = { + args: { + accessLevel: 'READ' + } +}; + +export const ViewAndManage: Story = { + args: { + accessLevel: 'CREATE_READ_UPDATE_DELETE' + } +}; diff --git a/libs/ui/src/lib/access-level-icon/access-level-icon.component.ts b/libs/ui/src/lib/access-level-icon/access-level-icon.component.ts new file mode 100644 index 000000000..3b5fc585d --- /dev/null +++ b/libs/ui/src/lib/access-level-icon/access-level-icon.component.ts @@ -0,0 +1,24 @@ +import { AccessLevel } from '@ghostfolio/common/types'; + +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { + createOutline, + lockClosedOutline, + lockOpenOutline +} from 'ionicons/icons'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IonIcon], + selector: 'gf-access-level-icon', + templateUrl: './access-level-icon.component.html' +}) +export class GfAccessLevelIconComponent { + public readonly accessLevel = input.required(); + + public constructor() { + addIcons({ createOutline, lockClosedOutline, lockOpenOutline }); + } +} diff --git a/libs/ui/src/lib/access-level-icon/index.ts b/libs/ui/src/lib/access-level-icon/index.ts new file mode 100644 index 000000000..8230daa02 --- /dev/null +++ b/libs/ui/src/lib/access-level-icon/index.ts @@ -0,0 +1 @@ +export * from './access-level-icon.component';