diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59cbf634d..2d7d053e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
+### Added
+
+- Added the _Restricted view and manage_ permission to the access to share the portfolio (experimental)
+
### Changed
- Improved the loading state of the symbol autocomplete component
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..1f8bb07b2 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,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 = {
+ 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', () => {
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..054044ca2 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,15 +1,19 @@
@switch (accessLevel()) {
+ @case ('CREATE_READ_RESTRICTED_UPDATE_DELETE') {
+ 🔏
+ Restricted view and manage
+ }
@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
index a230f60de..f62c32072 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
@@ -1,5 +1,3 @@
-import { CommonModule } from '@angular/common';
-import { IonIcon } from '@ionic/angular/standalone';
import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular';
@@ -10,13 +8,18 @@ export default {
component: GfAccessLevelIconComponent,
decorators: [
moduleMetadata({
- imports: [CommonModule, IonIcon]
+ imports: []
})
],
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 +32,12 @@ export const RestrictedView: Story = {
}
};
+export const RestrictedViewAndManage: Story = {
+ args: {
+ accessLevel: 'CREATE_READ_RESTRICTED_UPDATE_DELETE'
+ }
+};
+
export const View: Story = {
args: {
accessLevel: 'READ'
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
index 3b5fc585d..d53b58efa 100644
--- 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
@@ -1,24 +1,12 @@
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 });
- }
}