Browse Source

Merge branch 'main' into task/improve-type-filter-of-activities-table-component-to-only-list-activity-types-in-use

pull/7602/head
Thomas Kaul 1 week ago
committed by GitHub
parent
commit
599aa6e18a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      CHANGELOG.md
  2. 90
      apps/api/src/app/import/import.service.ts
  3. 6
      apps/api/src/app/import/interfaces/asset-profile-to-create.interface.ts
  4. 40
      apps/api/src/services/data-provider/data-provider.service.ts
  5. 13
      apps/client/src/app/components/access-table/access-table.component.html
  6. 22
      apps/client/src/app/components/access-table/access-table.component.ts
  7. 45
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  8. 15
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  9. 3
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/interfaces/interfaces.ts
  10. 29
      libs/common/src/lib/scopes.ts
  11. 2
      libs/common/src/lib/types/access-level.type.ts
  12. 2
      libs/common/src/lib/types/index.ts
  13. 3
      libs/ui/.storybook/main.mjs
  14. 1
      libs/ui/.storybook/preview.js
  15. 16
      libs/ui/src/lib/access-level-icon/access-level-icon.component.html
  16. 42
      libs/ui/src/lib/access-level-icon/access-level-icon.component.stories.ts
  17. 24
      libs/ui/src/lib/access-level-icon/access-level-icon.component.ts
  18. 1
      libs/ui/src/lib/access-level-icon/index.ts
  19. 1
      libs/ui/src/lib/account-selector/account-selector.component.stories.ts
  20. 1
      libs/ui/src/lib/currency-selector/currency-selector.component.stories.ts
  21. 1
      libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.stories.ts
  22. 1
      libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts
  23. 1
      libs/ui/src/lib/membership-card/membership-card.component.stories.ts
  24. 1
      libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts
  25. 1
      libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.stories.ts
  26. 1
      libs/ui/src/lib/tags-selector/tags-selector.component.stories.ts
  27. 1
      libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts
  28. 1
      libs/ui/src/lib/value/value.component.stories.ts

8
CHANGELOG.md

@ -10,9 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the type filter of the activities table on the activities page to only list the activity types in use (experimental) - Improved the type filter of the activities table on the activities page to only list the activity types in use (experimental)
- 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
- Improved the indexes of the order database table - Improved the indexes of the order database table
- 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`)
### Fixed
- Fixed the _Storybook_ setup by loading the `@angular/localize` polyfill centrally
- Fixed an issue in the activities import where an unused custom asset profile was created if the related activities were not imported
## 3.57.0 - 2026-08-21 ## 3.57.0 - 2026-08-21
### Added ### Added

90
apps/api/src/app/import/import.service.ts

@ -48,6 +48,7 @@ import { omit, uniqBy } from 'lodash';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { ImportDataDto } from './import-data.dto'; import { ImportDataDto } from './import-data.dto';
import { AssetProfileToCreate } from './interfaces/asset-profile-to-create.interface';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
@ -534,6 +535,8 @@ export class ImportService {
} }
} }
const assetProfilesToCreate: AssetProfileToCreate[] = [];
if (assetProfilesWithMarketDataDto?.length) { if (assetProfilesWithMarketDataDto?.length) {
const customAssetProfileNames = assetProfilesWithMarketDataDto const customAssetProfileNames = assetProfilesWithMarketDataDto
.filter(({ dataSource, name }) => { .filter(({ dataSource, name }) => {
@ -557,6 +560,7 @@ export class ImportService {
]); ]);
for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) {
let assetProfileToCreate: Prisma.SymbolProfileCreateInput;
let symbol = assetProfileWithMarketData.symbol; let symbol = assetProfileWithMarketData.symbol;
// Check if there is any existing asset profile // Check if there is any existing asset profile
@ -605,13 +609,10 @@ export class ImportService {
assetProfile.symbol = symbol; assetProfile.symbol = symbol;
if (!isDryRun) { if (!isDryRun) {
// Create a new asset profile assetProfileToCreate = {
const assetProfileObject: Prisma.SymbolProfileCreateInput = {
...assetProfile, ...assetProfile,
user: { connect: { id: user.id } } user: { connect: { id: user.id } }
}; };
await this.symbolProfileService.add(assetProfileObject);
} }
} }
@ -625,7 +626,6 @@ export class ImportService {
} }
if (!isDryRun) { if (!isDryRun) {
// Insert or update market data
const marketDataObjects = ( const marketDataObjects = (
assetProfileWithMarketData.marketData ?? [] assetProfileWithMarketData.marketData ?? []
).map((marketData) => { ).map((marketData) => {
@ -636,7 +636,40 @@ export class ImportService {
} as Prisma.MarketDataUpdateInput; } as Prisma.MarketDataUpdateInput;
}); });
await this.marketDataService.updateMany({ data: marketDataObjects }); if (assetProfileToCreate) {
const assetProfileToCreateIdentifier =
getAssetProfileIdentifier(assetProfileToCreate);
const duplicateAssetProfileToCreate = assetProfilesToCreate.find(
({ assetProfile }) => {
return (
getAssetProfileIdentifier(assetProfile) ===
assetProfileToCreateIdentifier
);
}
);
if (duplicateAssetProfileToCreate) {
// The import contains the same asset profile more than once,
// which would fail with a unique constraint violation. Keep the
// first asset profile and merge the market data into it.
duplicateAssetProfileToCreate.marketDataObjects.push(
...marketDataObjects
);
} else {
// Create the new asset profile and its market data later, once it
// is known which activities are imported
assetProfilesToCreate.push({
marketDataObjects,
assetProfile: assetProfileToCreate
});
}
} else {
// Insert or update market data
await this.marketDataService.updateMany({
data: marketDataObjects
});
}
} }
} }
} }
@ -719,6 +752,25 @@ export class ImportService {
return id === TAG_ID_DRAFT; return id === TAG_ID_DRAFT;
}) ?? { id: TAG_ID_DRAFT, name: 'DRAFT' }; }) ?? { id: TAG_ID_DRAFT, name: 'DRAFT' };
// Create the new asset profiles of the activities to import only, so that
// no unused asset profile remains, for example if no activity refers to
// the asset profile. An asset profile which is created before the
// validation of the activities would stay behind, because the import is
// not rolled back on an error.
if (!isDryRun) {
for (const {
assetProfile,
marketDataObjects
} of this.getAssetProfilesToCreate({
activities: activitiesExtendedWithErrors,
assetProfiles: assetProfilesToCreate
})) {
await this.symbolProfileService.add(assetProfile);
await this.marketDataService.updateMany({ data: marketDataObjects });
}
}
const activities: Activity[] = []; const activities: Activity[] = [];
for (const activity of activitiesExtendedWithErrors) { for (const activity of activitiesExtendedWithErrors) {
@ -934,7 +986,7 @@ export class ImportService {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Partial<Activity>[]> { }): Promise<(Partial<Activity> & Pick<Activity, 'assetProfile'>)[]> {
const { activities: existingActivities } = const { activities: existingActivities } =
await this.activitiesService.getActivities({ await this.activitiesService.getActivities({
userCurrency, userCurrency,
@ -1055,6 +1107,30 @@ export class ImportService {
return matchingAccountsOfUser[0]; return matchingAccountsOfUser[0];
} }
private getAssetProfilesToCreate({
activities,
assetProfiles
}: {
activities: Pick<Activity, 'assetProfile' | 'error'>[];
assetProfiles: AssetProfileToCreate[];
}) {
const assetProfileIdentifiersToImport = new Set(
activities
.filter(({ error }) => {
return !error;
})
.map(({ assetProfile }) => {
return getAssetProfileIdentifier(assetProfile);
})
);
return assetProfiles.filter(({ assetProfile }) => {
return assetProfileIdentifiersToImport.has(
getAssetProfileIdentifier(assetProfile)
);
});
}
private isUniqueAccount(accounts: AccountWithValue[]) { private isUniqueAccount(accounts: AccountWithValue[]) {
const uniqueAccountIds = new Set<string>(); const uniqueAccountIds = new Set<string>();

6
apps/api/src/app/import/interfaces/asset-profile-to-create.interface.ts

@ -0,0 +1,6 @@
import { Prisma } from '@prisma/client';
export interface AssetProfileToCreate {
assetProfile: Prisma.SymbolProfileCreateInput;
marketDataObjects: Prisma.MarketDataUpdateInput[];
}

40
apps/api/src/services/data-provider/data-provider.service.ts

@ -39,7 +39,7 @@ import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns'; import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; import { groupBy, isEmpty, isNumber, omit, uniqWith } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error'; import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error';
@ -272,23 +272,31 @@ export class DataProviderService implements OnModuleInit {
}); });
if (!assetProfiles[assetProfileIdentifier]) { if (!assetProfiles[assetProfileIdentifier]) {
if (
(dataSource === DataSource.MANUAL && type === 'BUY') ||
NON_INVESTMENT_ACTIVITY_TYPES.includes(type)
) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find( const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(assetProfile) => { (assetProfileWithMarketData) => {
return ( return (
assetProfile.dataSource === dataSource && assetProfileWithMarketData.dataSource === dataSource &&
assetProfile.symbol === symbol assetProfileWithMarketData.symbol === symbol
); );
} }
); );
// A custom asset profile of the import is created after the
// validation, thus the data provider cannot resolve it yet
if (
(dataSource === DataSource.MANUAL && type === 'BUY') ||
assetProfileInImport?.dataSource === DataSource.MANUAL ||
NON_INVESTMENT_ACTIVITY_TYPES.includes(type)
) {
assetProfiles[assetProfileIdentifier] = { assetProfiles[assetProfileIdentifier] = {
currency, ...omit(assetProfileInImport ?? {}, [
'dataSource',
'marketData',
'symbol'
]),
dataSource, dataSource,
symbol, symbol,
currency: assetProfileInImport?.currency ?? currency,
name: assetProfileInImport?.name ?? symbol name: assetProfileInImport?.name ?? symbol
}; };
@ -308,20 +316,6 @@ export class DataProviderService implements OnModuleInit {
)?.[assetProfileIdentifier]; )?.[assetProfileIdentifier];
} catch {} } catch {}
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
(profile) => {
return (
profile.dataSource === dataSource && profile.symbol === symbol
);
}
);
if (assetProfileInImport) {
Object.assign(assetProfile, assetProfileInImport);
}
}
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")` `${activityPath}.symbol ("${symbol}") is not valid for the specified data source ("${maskedDataSource}")`

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,

3
libs/ui/.storybook/main.mjs

@ -9,6 +9,9 @@ const config = {
getAbsolutePath('@storybook/addon-docs'), getAbsolutePath('@storybook/addon-docs'),
getAbsolutePath('@storybook/addon-themes') getAbsolutePath('@storybook/addon-themes')
], ],
core: {
disableTelemetry: true
},
framework: { framework: {
name: getAbsolutePath('@storybook/angular'), name: getAbsolutePath('@storybook/angular'),
options: {} options: {}

1
libs/ui/.storybook/preview.js

@ -1,3 +1,4 @@
import '@angular/localize/init';
import { withThemeByClassName } from '@storybook/addon-themes'; import { withThemeByClassName } from '@storybook/addon-themes';
const preview = { const preview = {

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';

1
libs/ui/src/lib/account-selector/account-selector.component.stories.ts

@ -3,7 +3,6 @@ import { AccountWithPlatform } from '@ghostfolio/common/types';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ANIMATION_MODULE_TYPE, importProvidersFrom } from '@angular/core'; import { ANIMATION_MODULE_TYPE, importProvidersFrom } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import '@angular/localize/init';
import { import {
applicationConfig, applicationConfig,
Meta, Meta,

1
libs/ui/src/lib/currency-selector/currency-selector.component.stories.ts

@ -1,6 +1,5 @@
import { ANIMATION_MODULE_TYPE } from '@angular/core'; import { ANIMATION_MODULE_TYPE } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import '@angular/localize/init';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';

1
libs/ui/src/lib/fear-and-greed-index/fear-and-greed-index.component.stories.ts

@ -1,4 +1,3 @@
import '@angular/localize/init';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';

1
libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts

@ -3,7 +3,6 @@ import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ANIMATION_MODULE_TYPE } from '@angular/core'; import { ANIMATION_MODULE_TYPE } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import '@angular/localize/init';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { provideNativeDateAdapter } from '@angular/material/core'; import { provideNativeDateAdapter } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatDatepickerModule } from '@angular/material/datepicker';

1
libs/ui/src/lib/membership-card/membership-card.component.stories.ts

@ -1,5 +1,4 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import '@angular/localize/init';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { ActivatedRoute, RouterModule } from '@angular/router'; import { ActivatedRoute, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';

1
libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts

@ -1,6 +1,5 @@
import { AccountWithPlatform } from '@ghostfolio/common/types'; import { AccountWithPlatform } from '@ghostfolio/common/types';
import '@angular/localize/init';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { holdings } from '../mocks/holdings'; import { holdings } from '../mocks/holdings';

1
libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.stories.ts

@ -1,5 +1,4 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import '@angular/localize/init';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';

1
libs/ui/src/lib/tags-selector/tags-selector.component.stories.ts

@ -1,6 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ANIMATION_MODULE_TYPE } from '@angular/core'; import { ANIMATION_MODULE_TYPE } from '@angular/core';
import '@angular/localize/init';
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; import { Meta, moduleMetadata, StoryObj } from '@storybook/angular';
import { GfTagsSelectorComponent } from './tags-selector.component'; import { GfTagsSelectorComponent } from './tags-selector.component';

1
libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts

@ -1,7 +1,6 @@
import { DEFAULT_COLOR_SCHEME } from '@ghostfolio/common/config'; import { DEFAULT_COLOR_SCHEME } from '@ghostfolio/common/config';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import '@angular/localize/init';
import { moduleMetadata } from '@storybook/angular'; import { moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';

1
libs/ui/src/lib/value/value.component.stories.ts

@ -1,5 +1,4 @@
import { ANIMATION_MODULE_TYPE } from '@angular/core'; import { ANIMATION_MODULE_TYPE } from '@angular/core';
import '@angular/localize/init';
import { applicationConfig, moduleMetadata } from '@storybook/angular'; import { applicationConfig, moduleMetadata } from '@storybook/angular';
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';

Loading…
Cancel
Save