From 84099e7149fc597f960fafb3d03d8a1d2c930856 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:19:49 +0200 Subject: [PATCH] Add restricted view and manage permission to access --- .../create-or-update-access-dialog.html | 13 ++++ libs/common/src/lib/scopes.spec.ts | 67 +++++++++++++++++++ libs/common/src/lib/scopes.ts | 21 ++++-- .../common/src/lib/types/access-level.type.ts | 5 +- .../access-level-icon.component.html | 4 ++ .../access-level-icon.component.stories.ts | 13 +++- 6 files changed, 115 insertions(+), 8 deletions(-) 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 cf0d8f2be..b2957f9c2 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 @@ -83,6 +83,19 @@ @if (accessForm.get('type')?.value === 'PRIVATE') { + diff --git a/libs/common/src/lib/scopes.spec.ts b/libs/common/src/lib/scopes.spec.ts index 223a394b9..1493fe99a 100644 --- a/libs/common/src/lib/scopes.spec.ts +++ b/libs/common/src/lib/scopes.spec.ts @@ -3,13 +3,16 @@ import { SCOPES_OF_READ_ACCESS, SCOPES_OF_READ_RESTRICTED_ACCESS, SCOPES_OF_WRITE_ACCESS, + getAccessLevel, getScopesOfAccess, + getScopesOfAccessLevel, getScopesOfOwnAccess, getScopesOfUnrestrictedImpersonation, hasAnyScopeOfWriteAccess, hasScope, scopes } from '@ghostfolio/common/scopes'; +import { AccessLevel } from '@ghostfolio/common/types'; describe('Scopes', () => { describe('Scopes of read access', () => { @@ -57,6 +60,70 @@ 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 = { + 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', () => { + const scopesOfAccessLevel = getScopesOfAccessLevel( + 'CREATE_READ_RESTRICTED_UPDATE_DELETE' + ); + + expect(scopesOfAccessLevel).toEqual([ + ...SCOPES_OF_READ_RESTRICTED_ACCESS, + ...SCOPES_OF_WRITE_ACCESS + ]); + expect(scopesOfAccessLevel).not.toContain(scopes.portfolioReadValues); + }); + + // 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', () => { it('Gives the scopes of the access', () => { expect( diff --git a/libs/common/src/lib/scopes.ts b/libs/common/src/lib/scopes.ts index ee74e1742..27cba0e72 100644 --- a/libs/common/src/lib/scopes.ts +++ b/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 * ineffective even if it is stored. */ const SCOPES_OF_TYPE: Record = { - 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), PUBLIC: SCOPES_OF_PUBLIC_ACCESS }; @@ -76,13 +81,15 @@ const SCOPES_OF_TYPE: Record = { * Access level which the scopes of an access grant */ export function getAccessLevel(aScopes: string[] = []): AccessLevel { + const hasScopeToReadValues = hasScope(aScopes, scopes.portfolioReadValues); + if (hasAnyScopeOfWriteAccess(aScopes)) { - return 'CREATE_READ_UPDATE_DELETE'; + return hasScopeToReadValues + ? 'CREATE_READ_UPDATE_DELETE' + : 'CREATE_READ_RESTRICTED_UPDATE_DELETE'; } - return hasScope(aScopes, scopes.portfolioReadValues) - ? 'READ' - : 'READ_RESTRICTED'; + return hasScopeToReadValues ? 'READ' : 'READ_RESTRICTED'; } export function getScopesOfAccess({ @@ -105,6 +112,8 @@ export function getScopesOfAccess({ */ export function getScopesOfAccessLevel(aAccessLevel: AccessLevel): Scope[] { switch (aAccessLevel) { + case 'CREATE_READ_RESTRICTED_UPDATE_DELETE': + return [...SCOPES_OF_READ_RESTRICTED_ACCESS, ...SCOPES_OF_WRITE_ACCESS]; case 'CREATE_READ_UPDATE_DELETE': return [...SCOPES_OF_READ_ACCESS, ...SCOPES_OF_WRITE_ACCESS]; case 'READ': diff --git a/libs/common/src/lib/types/access-level.type.ts b/libs/common/src/lib/types/access-level.type.ts index 33f0f11a2..7db310e0e 100644 --- a/libs/common/src/lib/types/access-level.type.ts +++ b/libs/common/src/lib/types/access-level.type.ts @@ -1,2 +1,5 @@ export type AccessLevel = - 'CREATE_READ_UPDATE_DELETE' | 'READ' | 'READ_RESTRICTED'; + | 'CREATE_READ_RESTRICTED_UPDATE_DELETE' + | 'CREATE_READ_UPDATE_DELETE' + | 'READ' + | 'READ_RESTRICTED'; 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 index c610d4b57..f91f3968b 100644 --- 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 @@ -1,5 +1,9 @@ @switch (accessLevel()) { + @case ('CREATE_READ_RESTRICTED_UPDATE_DELETE') { + + Restricted view and manage + } @case ('CREATE_READ_UPDATE_DELETE') { View and manage 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 index a230f60de..aa294f044 100644 --- 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 @@ -16,7 +16,12 @@ export default { argTypes: { accessLevel: { 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; @@ -29,6 +34,12 @@ export const RestrictedView: Story = { } }; +export const RestrictedViewAndManage: Story = { + args: { + accessLevel: 'CREATE_READ_RESTRICTED_UPDATE_DELETE' + } +}; + export const View: Story = { args: { accessLevel: 'READ'