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'