Browse Source

Task/extract access level icon to reusable component (#7692)

* Extract access level icon to reusable component

* Update changelog
pull/7602/head^2
Thomas Kaul 6 days ago
committed by GitHub
parent
commit
5232e8f366
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 13
      apps/client/src/app/components/access-table/access-table.component.html
  3. 22
      apps/client/src/app/components/access-table/access-table.component.ts
  4. 45
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  5. 15
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  6. 3
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
  7. 29
      libs/common/src/lib/scopes.ts
  8. 2
      libs/common/src/lib/types/access-level.type.ts
  9. 2
      libs/common/src/lib/types/index.ts
  10. 16
      libs/ui/src/lib/access-level-icon/access-level-icon.component.html
  11. 42
      libs/ui/src/lib/access-level-icon/access-level-icon.component.stories.ts
  12. 24
      libs/ui/src/lib/access-level-icon/access-level-icon.component.ts
  13. 1
      libs/ui/src/lib/access-level-icon/index.ts

2
CHANGELOG.md

@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### 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 - Disabled the telemetry in the _Storybook_ setup
- Upgraded the `Node.js` engine from version `>=22.18.0` to `>=22.22.3` (`package.json`) - Upgraded the `Node.js` engine from version `>=22.18.0` to `>=22.22.3` (`package.json`)

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

@ -17,18 +17,7 @@
<ng-container matColumnDef="type"> <ng-container matColumnDef="type">
<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"> <gf-access-level-icon [accessLevel]="getAccessLevel(element.scopes)" />
@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>
}
</div>
</td> </td>
</ng-container> </ng-container>

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

@ -1,11 +1,8 @@
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 { import { getAccessLevel } from '@ghostfolio/common/scopes';
hasAnyScopeOfWriteAccess, import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon';
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';
@ -31,8 +28,6 @@ import {
createOutline, createOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline, linkOutline,
lockClosedOutline,
lockOpenOutline,
removeCircleOutline removeCircleOutline
} from 'ionicons/icons'; } from 'ionicons/icons';
import ms from 'ms'; import ms from 'ms';
@ -42,6 +37,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
ClipboardModule, ClipboardModule,
GfAccessLevelIconComponent,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatMenuModule, MatMenuModule,
@ -75,6 +71,8 @@ export class GfAccessTableComponent {
return columns; return columns;
}); });
protected readonly getAccessLevel = getAccessLevel;
protected readonly isLoading = computed(() => { protected readonly isLoading = computed(() => {
return !this.accesses(); return !this.accesses();
}); });
@ -89,8 +87,6 @@ export class GfAccessTableComponent {
createOutline, createOutline,
ellipsisHorizontal, ellipsisHorizontal,
linkOutline, linkOutline,
lockClosedOutline,
lockOpenOutline,
removeCircleOutline removeCircleOutline
}); });
@ -105,14 +101,6 @@ export class GfAccessTableComponent {
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`; 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) { protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId)); this.clipboard.copy(this.getPublicUrl(aId));

45
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 { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { import {
SCOPES_OF_READ_ACCESS,
SCOPES_OF_READ_RESTRICTED_ACCESS,
SCOPES_OF_WRITE_ACCESS,
Scope, Scope,
hasAnyScopeOfWriteAccess, getAccessLevel,
getScopesOfAccessLevel,
hasScope, hasScope,
scopes scopes
} from '@ghostfolio/common/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 { validateObjectForForm } from '@ghostfolio/common/utils';
import { GfAccessLevelIconComponent } from '@ghostfolio/ui/access-level-icon';
import { NotificationService } from '@ghostfolio/ui/notifications'; import { NotificationService } from '@ghostfolio/ui/notifications';
import { import {
GfPortfolioFilterFormComponent, GfPortfolioFilterFormComponent,
@ -52,16 +51,14 @@ 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 { import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
AccessLevel,
CreateOrUpdateAccessDialogParams
} from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' }, host: { class: 'h-100' },
imports: [ imports: [
FormsModule, FormsModule,
GfAccessLevelIconComponent,
GfPortfolioFilterFormComponent, GfPortfolioFilterFormComponent,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
@ -120,7 +117,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
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), accessLevel: getAccessLevel(access?.scopes),
alias: [access?.alias ?? ''], alias: [access?.alias ?? ''],
filters: [null], filters: [null],
granteeUserId: [ granteeUserId: [
@ -172,6 +169,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.loadHoldings(); this.loadHoldings();
} }
protected get accessLevel(): AccessLevel {
return this.accessForm?.get('accessLevel')?.value as AccessLevel;
}
protected onCancel() { protected onCancel() {
this.dialogRef.close(); this.dialogRef.close();
} }
@ -191,28 +192,18 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
} }
private buildScopes(): Scope[] { private buildScopes(): Scope[] {
const accessLevel = this.accessForm.get('accessLevel')
?.value as AccessLevel;
const scopesOfAccess = this.data.access?.scopes ?? []; const scopesOfAccess = this.data.access?.scopes ?? [];
if ( if (
scopesOfAccess.length > 0 && scopesOfAccess.length > 0 &&
accessLevel === this.getAccessLevel(scopesOfAccess) this.accessLevel === getAccessLevel(scopesOfAccess)
) { ) {
return Object.values(scopes).filter((scope) => { return Object.values(scopes).filter((scope) => {
return hasScope(scopesOfAccess, scope); return hasScope(scopesOfAccess, scope);
}); });
} }
switch (accessLevel) { return getScopesOfAccessLevel(this.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() {
@ -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() { private loadHoldings() {
this.dataService this.dataService
.fetchPortfolioHoldings() .fetchPortfolioHoldings()

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

@ -37,15 +37,22 @@
<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="accessLevel"> <mat-select formControlName="accessLevel">
<mat-option i18n value="READ_RESTRICTED">Restricted view</mat-option> <mat-select-trigger>
<gf-access-level-icon [accessLevel]="accessLevel" />
</mat-select-trigger>
<mat-option value="READ_RESTRICTED">
<gf-access-level-icon accessLevel="READ_RESTRICTED" />
</mat-option>
@if (accessForm.get('type')?.value === 'PRIVATE') { @if (accessForm.get('type')?.value === 'PRIVATE') {
<mat-option i18n value="READ">View</mat-option> <mat-option value="READ">
<gf-access-level-icon accessLevel="READ" />
</mat-option>
<mat-option <mat-option
i18n
value="CREATE_READ_UPDATE_DELETE" value="CREATE_READ_UPDATE_DELETE"
[disabled]="!canGrantWriteAccess" [disabled]="!canGrantWriteAccess"
>View and manage</mat-option
> >
<gf-access-level-icon accessLevel="CREATE_READ_UPDATE_DELETE" />
</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,6 +3,3 @@ 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';

29
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 * 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 * 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; 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({ export function getScopesOfAccess({
granteeUserId, granteeUserId,
scopes: scopesOfAccess 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 * Scopes of a user acting on their own data, which is unrestricted. The
* permissions of the role are evaluated separately. * permissions of the role are evaluated separately.

2
libs/common/src/lib/types/access-level.type.ts

@ -0,0 +1,2 @@
export type AccessLevel =
'CREATE_READ_UPDATE_DELETE' | 'READ' | 'READ_RESTRICTED';

2
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 { AccessType } from './access-type.type';
import type { AccessWithGranteeUser } from './access-with-grantee-user.type'; import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import type { AccountWithBalance } from './account-with-balance.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'; import type { ViewMode } from './view-mode.type';
export type { export type {
AccessLevel,
AccessType, AccessType,
AccessWithGranteeUser, AccessWithGranteeUser,
AccountWithBalance, AccountWithBalance,

16
libs/ui/src/lib/access-level-icon/access-level-icon.component.html

@ -0,0 +1,16 @@
<span class="align-items-center d-flex">
@switch (accessLevel()) {
@case ('CREATE_READ_UPDATE_DELETE') {
<ion-icon class="mr-1" name="create-outline" />
<ng-container i18n>View and manage</ng-container>
}
@case ('READ') {
<ion-icon class="mr-1" name="lock-open-outline" />
<ng-container i18n>View</ng-container>
}
@case ('READ_RESTRICTED') {
<ion-icon class="mr-1" name="lock-closed-outline" />
<ng-container i18n>Restricted view</ng-container>
}
}
</span>

42
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<GfAccessLevelIconComponent>;
type Story = StoryObj<GfAccessLevelIconComponent>;
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'
}
};

24
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<AccessLevel>();
public constructor() {
addIcons({ createOutline, lockClosedOutline, lockOpenOutline });
}
}

1
libs/ui/src/lib/access-level-icon/index.ts

@ -0,0 +1 @@
export * from './access-level-icon.component';
Loading…
Cancel
Save