Browse Source

Task/add restricted view and manage permission to access (#7740)

* Add restricted view and manage permission to access

* Update changelog
pull/7743/head
Thomas Kaul 1 day ago
committed by GitHub
parent
commit
ee40119148
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 13
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  3. 64
      libs/common/src/lib/scopes.spec.ts
  4. 21
      libs/common/src/lib/scopes.ts
  5. 5
      libs/common/src/lib/types/access-level.type.ts
  6. 10
      libs/ui/src/lib/access-level-icon/access-level-icon.component.html
  7. 17
      libs/ui/src/lib/access-level-icon/access-level-icon.component.stories.ts
  8. 12
      libs/ui/src/lib/access-level-icon/access-level-icon.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 _Restricted view and manage_ permission to the access to share the portfolio (experimental)
### Changed ### Changed
- Improved the loading state of the symbol autocomplete component - Improved the loading state of the symbol autocomplete component

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

@ -83,6 +83,19 @@
<gf-access-level-icon accessLevel="READ_RESTRICTED" /> <gf-access-level-icon accessLevel="READ_RESTRICTED" />
</mat-option> </mat-option>
@if (accessForm.get('type')?.value === 'PRIVATE') { @if (accessForm.get('type')?.value === 'PRIVATE') {
<!--
The permission to change the data without the monetary values is
not available yet, because it is intended for the model context
protocol, which exposes read tools only
<mat-option
value="CREATE_READ_RESTRICTED_UPDATE_DELETE"
[disabled]="!canGrantWriteAccess"
>
<gf-access-level-icon
accessLevel="CREATE_READ_RESTRICTED_UPDATE_DELETE"
/>
</mat-option>
-->
<mat-option value="READ"> <mat-option value="READ">
<gf-access-level-icon accessLevel="READ" /> <gf-access-level-icon accessLevel="READ" />
</mat-option> </mat-option>

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

@ -3,13 +3,16 @@ import {
SCOPES_OF_READ_ACCESS, SCOPES_OF_READ_ACCESS,
SCOPES_OF_READ_RESTRICTED_ACCESS, SCOPES_OF_READ_RESTRICTED_ACCESS,
SCOPES_OF_WRITE_ACCESS, SCOPES_OF_WRITE_ACCESS,
getAccessLevel,
getScopesOfAccess, getScopesOfAccess,
getScopesOfAccessLevel,
getScopesOfOwnAccess, getScopesOfOwnAccess,
getScopesOfUnrestrictedImpersonation, getScopesOfUnrestrictedImpersonation,
hasAnyScopeOfWriteAccess, hasAnyScopeOfWriteAccess,
hasScope, hasScope,
scopes scopes
} from '@ghostfolio/common/scopes'; } from '@ghostfolio/common/scopes';
import { AccessLevel } from '@ghostfolio/common/types';
describe('Scopes', () => { describe('Scopes', () => {
describe('Scopes of read access', () => { describe('Scopes of read access', () => {
@ -57,6 +60,67 @@ describe('Scopes', () => {
}); });
}); });
describe('Get access level', () => {
it('Write scopes with the monetary values', () => {
expect(
getAccessLevel([...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS])
).toEqual('CREATE_READ_UPDATE_DELETE');
});
it('Write scopes without the monetary values', () => {
expect(
getAccessLevel([
...SCOPES_OF_READ_RESTRICTED_ACCESS,
...SCOPES_OF_WRITE_ACCESS
])
).toEqual('CREATE_READ_RESTRICTED_UPDATE_DELETE');
});
it('Read scopes with the monetary values', () => {
expect(getAccessLevel([...SCOPES_OF_READ_ACCESS])).toEqual('READ');
});
it('Read scopes without the monetary values', () => {
expect(getAccessLevel([...SCOPES_OF_READ_RESTRICTED_ACCESS])).toEqual(
'READ_RESTRICTED'
);
});
it('Without scopes', () => {
expect(getAccessLevel(undefined)).toEqual('READ_RESTRICTED');
});
});
describe('Get scopes of access level', () => {
// A new access level has to be added here deliberately, because the type
// of the record is exhaustive
const accessLevels: Record<AccessLevel, true> = {
CREATE_READ_RESTRICTED_UPDATE_DELETE: true,
CREATE_READ_UPDATE_DELETE: true,
READ: true,
READ_RESTRICTED: true
};
it('Grants the write scopes without the monetary values', () => {
expect(
getScopesOfAccessLevel('CREATE_READ_RESTRICTED_UPDATE_DELETE')
).toEqual([
...SCOPES_OF_READ_RESTRICTED_ACCESS,
...SCOPES_OF_WRITE_ACCESS
]);
});
// The dialog compares the access level of the stored scopes with the
// selected access level, hence both functions have to be inverse
for (const accessLevel of Object.keys(accessLevels) as AccessLevel[]) {
it(`Is inverse to the access level of ${accessLevel}`, () => {
expect(getAccessLevel(getScopesOfAccessLevel(accessLevel))).toEqual(
accessLevel
);
});
}
});
describe('Get scopes of access', () => { describe('Get scopes of access', () => {
it('Gives the scopes of the access', () => { it('Gives the scopes of the access', () => {
expect( expect(

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

@ -62,12 +62,17 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] =
}); });
/** /**
* Ceiling of scopes per access type. The scopes stored on an access are * Maximum scopes per access type. The scopes stored on an access are
* intersected with it, hence a scope which the type does not permit stays * intersected with it, hence a scope which the type does not permit stays
* ineffective even if it is stored. * ineffective even if it is stored.
*/ */
const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = { const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = {
MCP: SCOPES_OF_READ_RESTRICTED_ACCESS, MCP: [
...SCOPES_OF_READ_RESTRICTED_ACCESS
// Write scope is not permitted yet, because the controller exposes read
// tools only
// ...SCOPES_OF_WRITE_ACCESS
],
PRIVATE: Object.values(scopes), PRIVATE: Object.values(scopes),
PUBLIC: SCOPES_OF_PUBLIC_ACCESS PUBLIC: SCOPES_OF_PUBLIC_ACCESS
}; };
@ -76,13 +81,15 @@ const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = {
* Access level which the scopes of an access grant * Access level which the scopes of an access grant
*/ */
export function getAccessLevel(aScopes: string[] = []): AccessLevel { export function getAccessLevel(aScopes: string[] = []): AccessLevel {
const hasScopeToReadValues = hasScope(aScopes, scopes.portfolioReadValues);
if (hasAnyScopeOfWriteAccess(aScopes)) { if (hasAnyScopeOfWriteAccess(aScopes)) {
return 'CREATE_READ_UPDATE_DELETE'; return hasScopeToReadValues
? 'CREATE_READ_UPDATE_DELETE'
: 'CREATE_READ_RESTRICTED_UPDATE_DELETE';
} }
return hasScope(aScopes, scopes.portfolioReadValues) return hasScopeToReadValues ? 'READ' : 'READ_RESTRICTED';
? 'READ'
: 'READ_RESTRICTED';
} }
export function getScopesOfAccess({ export function getScopesOfAccess({
@ -105,6 +112,8 @@ export function getScopesOfAccess({
*/ */
export function getScopesOfAccessLevel(aAccessLevel: AccessLevel): Scope[] { export function getScopesOfAccessLevel(aAccessLevel: AccessLevel): Scope[] {
switch (aAccessLevel) { switch (aAccessLevel) {
case 'CREATE_READ_RESTRICTED_UPDATE_DELETE':
return [...SCOPES_OF_READ_RESTRICTED_ACCESS, ...SCOPES_OF_WRITE_ACCESS];
case 'CREATE_READ_UPDATE_DELETE': case 'CREATE_READ_UPDATE_DELETE':
return [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS]; return [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS];
case 'READ': case 'READ':

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

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

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

@ -1,15 +1,19 @@
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
@switch (accessLevel()) { @switch (accessLevel()) {
@case ('CREATE_READ_RESTRICTED_UPDATE_DELETE') {
<span aria-hidden="true" class="mr-1">🔏</span>
<ng-container i18n>Restricted view and manage</ng-container>
}
@case ('CREATE_READ_UPDATE_DELETE') { @case ('CREATE_READ_UPDATE_DELETE') {
<ion-icon class="mr-1" name="create-outline" /> <span aria-hidden="true" class="mr-1">🖋️</span>
<ng-container i18n>View and manage</ng-container> <ng-container i18n>View and manage</ng-container>
} }
@case ('READ') { @case ('READ') {
<ion-icon class="mr-1" name="lock-open-outline" /> <span aria-hidden="true" class="mr-1">🔓</span>
<ng-container i18n>View</ng-container> <ng-container i18n>View</ng-container>
} }
@case ('READ_RESTRICTED') { @case ('READ_RESTRICTED') {
<ion-icon class="mr-1" name="lock-closed-outline" /> <span aria-hidden="true" class="mr-1">🔒</span>
<ng-container i18n>Restricted view</ng-container> <ng-container i18n>Restricted view</ng-container>
} }
} }

17
libs/ui/src/lib/access-level-icon/access-level-icon.component.stories.ts

@ -1,5 +1,3 @@
import { CommonModule } from '@angular/common';
import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
@ -10,13 +8,18 @@ export default {
component: GfAccessLevelIconComponent, component: GfAccessLevelIconComponent,
decorators: [ decorators: [
moduleMetadata({ moduleMetadata({
imports: [CommonModule, IonIcon] imports: []
}) })
], ],
argTypes: { argTypes: {
accessLevel: { accessLevel: {
control: 'select', control: 'select',
options: ['CREATE_READ_UPDATE_DELETE', 'READ', 'READ_RESTRICTED'] options: [
'CREATE_READ_RESTRICTED_UPDATE_DELETE',
'CREATE_READ_UPDATE_DELETE',
'READ',
'READ_RESTRICTED'
]
} }
} }
} as Meta<GfAccessLevelIconComponent>; } as Meta<GfAccessLevelIconComponent>;
@ -29,6 +32,12 @@ export const RestrictedView: Story = {
} }
}; };
export const RestrictedViewAndManage: Story = {
args: {
accessLevel: 'CREATE_READ_RESTRICTED_UPDATE_DELETE'
}
};
export const View: Story = { export const View: Story = {
args: { args: {
accessLevel: 'READ' accessLevel: 'READ'

12
libs/ui/src/lib/access-level-icon/access-level-icon.component.ts

@ -1,24 +1,12 @@
import { AccessLevel } from '@ghostfolio/common/types'; import { AccessLevel } from '@ghostfolio/common/types';
import { ChangeDetectionStrategy, Component, input } from '@angular/core'; 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({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IonIcon],
selector: 'gf-access-level-icon', selector: 'gf-access-level-icon',
templateUrl: './access-level-icon.component.html' templateUrl: './access-level-icon.component.html'
}) })
export class GfAccessLevelIconComponent { export class GfAccessLevelIconComponent {
public readonly accessLevel = input.required<AccessLevel>(); public readonly accessLevel = input.required<AccessLevel>();
public constructor() {
addIcons({ createOutline, lockClosedOutline, lockOpenOutline });
}
} }

Loading…
Cancel
Save