Germán Martín 1 day ago
committed by GitHub
parent
commit
dd6e4be338
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 40
      apps/api/src/app/access/access-filter.dto.ts
  3. 1
      apps/api/src/app/access/access-settings.interface.ts
  4. 89
      apps/api/src/app/access/access.controller.ts
  5. 5
      apps/api/src/app/access/create-access.dto.ts
  6. 5
      apps/api/src/app/access/update-access.dto.ts
  7. 154
      apps/api/src/app/endpoints/public/public.controller.ts
  8. 221
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  9. 74
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  10. 6
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss
  11. 15
      apps/client/src/app/components/user-account-access/user-account-access.component.ts
  12. 12
      libs/common/src/lib/interfaces/access.interface.ts
  13. 4
      libs/common/src/lib/interfaces/index.ts

1
CHANGELOG.md

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added a close holding button to the holding detail dialog
- Added the _Sponsors_ section to the about page
- Extended the user detail dialog in the users section of the admin control panel
- Enable filtering (by account, holdings, tag or asset class) in public access
### Changed

40
apps/api/src/app/access/access-filter.dto.ts

@ -0,0 +1,40 @@
import { DataSource } from '@prisma/client';
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsOptional,
IsString,
ValidateNested
} from 'class-validator';
export class HoldingFilterDto {
@IsEnum(DataSource)
dataSource: DataSource;
@IsString()
symbol: string;
}
export class AccessFilterDto {
@IsArray()
@IsOptional()
@IsString({ each: true })
accountIds?: string[];
@IsArray()
@IsOptional()
@IsString({ each: true })
assetClasses?: string[];
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => HoldingFilterDto)
holdings?: HoldingFilterDto[];
@IsArray()
@IsOptional()
@IsString({ each: true })
tagIds?: string[];
}

1
apps/api/src/app/access/access-settings.interface.ts

@ -0,0 +1 @@
export type { AccessSettings } from '@ghostfolio/common/interfaces';

89
apps/api/src/app/access/access.controller.ts

@ -22,6 +22,7 @@ import { AuthGuard } from '@nestjs/passport';
import { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessSettings } from './access-settings.interface';
import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto';
import { UpdateAccessDto } from './update-access.dto';
@ -34,40 +35,6 @@ export class AccessController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({
include: {
granteeUser: true
},
orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }],
where: { userId: this.request.user.id }
});
return accessesWithGranteeUser.map(
({ alias, granteeUser, id, permissions }) => {
if (granteeUser) {
return {
alias,
id,
permissions,
grantee: granteeUser?.id,
type: 'PRIVATE'
};
}
return {
alias,
id,
permissions,
grantee: 'Public',
type: 'PUBLIC'
};
}
);
}
@HasPermission(permissions.createAccess)
@Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -85,12 +52,17 @@ export class AccessController {
}
try {
const settings: AccessSettings = data.filter
? { filter: data.filter }
: {};
return this.accessService.createAccess({
alias: data.alias || undefined,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: undefined,
permissions: data.permissions,
settings: settings as any,
user: { connect: { id: this.request.user.id } }
});
} catch {
@ -122,6 +94,48 @@ export class AccessController {
});
}
@Get()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAllAccesses(): Promise<Access[]> {
const accessesWithGranteeUser = await this.accessService.accesses({
include: {
granteeUser: true
},
orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }],
where: { userId: this.request.user.id }
});
return accessesWithGranteeUser.map(
({
alias,
granteeUser,
id,
permissions: accessPermissions,
settings
}) => {
if (granteeUser) {
return {
alias,
id,
permissions: accessPermissions,
settings: settings as AccessSettings,
grantee: granteeUser?.id,
type: 'PRIVATE'
};
}
return {
alias,
id,
permissions: accessPermissions,
settings: settings as AccessSettings,
grantee: 'Public',
type: 'PUBLIC'
};
}
);
}
@HasPermission(permissions.updateAccess)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -152,13 +166,18 @@ export class AccessController {
}
try {
const settings: AccessSettings = data.filter
? { filter: data.filter }
: {};
return this.accessService.updateAccess({
data: {
alias: data.alias,
granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }
: { disconnect: true },
permissions: data.permissions
permissions: data.permissions,
settings: settings as any
},
where: { id }
});

5
apps/api/src/app/access/create-access.dto.ts

@ -1,3 +1,5 @@
import { AccessFilter } from '@ghostfolio/common/interfaces';
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
@ -6,6 +8,9 @@ export class CreateAccessDto {
@IsString()
alias?: string;
@IsOptional()
filter?: AccessFilter;
@IsOptional()
@IsUUID()
granteeUserId?: string;

5
apps/api/src/app/access/update-access.dto.ts

@ -1,3 +1,5 @@
import { AccessFilter } from '@ghostfolio/common/interfaces';
import { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
@ -6,6 +8,9 @@ export class UpdateAccessDto {
@IsString()
alias?: string;
@IsOptional()
filter?: AccessFilter;
@IsOptional()
@IsUUID()
granteeUserId?: string;

154
apps/api/src/app/endpoints/public/public.controller.ts

@ -8,7 +8,11 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getSum } from '@ghostfolio/common/helper';
import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces';
import {
AccessSettings,
Filter,
PublicPortfolioResponse
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -20,7 +24,7 @@ import {
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Type as ActivityType } from '@prisma/client';
import { Type as ActivityType, AssetSubClass } from '@prisma/client';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -61,6 +65,62 @@ export class PublicController {
hasDetails = user.subscription.type === 'Premium';
}
// Get filter configuration from access settings
const accessSettings = (access.settings ?? {}) as AccessSettings;
const accessFilter = accessSettings.filter;
// Convert access filter to portfolio filters
const portfolioFilters: Filter[] = [];
if (accessFilter) {
// Add account filters
if (accessFilter.accountIds && accessFilter.accountIds.length > 0) {
portfolioFilters.push(
...accessFilter.accountIds.map((accountId) => ({
id: accountId,
type: 'ACCOUNT' as const
}))
);
}
// Add asset class filters
if (accessFilter.assetClasses && accessFilter.assetClasses.length > 0) {
portfolioFilters.push(
...accessFilter.assetClasses.map((assetClass) => ({
id: assetClass,
type: 'ASSET_CLASS' as const
}))
);
}
// Add tag filters
if (accessFilter.tagIds && accessFilter.tagIds.length > 0) {
portfolioFilters.push(
...accessFilter.tagIds.map((tagId) => ({
id: tagId,
type: 'TAG' as const
}))
);
}
// Add holding filters (symbol + dataSource)
// Each holding needs both DATA_SOURCE and SYMBOL filters
if (accessFilter.holdings && accessFilter.holdings.length > 0) {
accessFilter.holdings.forEach((holding) => {
portfolioFilters.push(
{
id: holding.dataSource,
type: 'DATA_SOURCE' as const
},
{
id: holding.symbol,
type: 'SYMBOL' as const
}
);
});
}
}
const [
{ createdAt, holdings, markets },
{ performance: performance1d },
@ -68,6 +128,7 @@ export class PublicController {
{ performance: performanceYtd }
] = await Promise.all([
this.portfolioService.getDetails({
filters: portfolioFilters.length > 0 ? portfolioFilters : undefined,
impersonationId: access.userId,
userId: user.id,
withMarkets: true
@ -75,29 +136,84 @@ export class PublicController {
...['1d', 'max', 'ytd'].map((dateRange) => {
return this.portfolioService.getPerformance({
dateRange,
filters: portfolioFilters.length > 0 ? portfolioFilters : undefined,
impersonationId: undefined,
userId: user.id
});
})
]);
// Filter out only the base currency cash holdings
const baseCurrency =
user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY;
const filteredHoldings = Object.fromEntries(
Object.entries(holdings).filter(([, holding]) => {
// Remove only cash holdings that match the base currency
const isCash = holding.assetSubClass === AssetSubClass.CASH;
const isBaseCurrency = holding.symbol === baseCurrency;
return !(isCash && isBaseCurrency);
})
);
// Use filters for activities, but exclude DATA_SOURCE/SYMBOL filters
// if there are multiple holdings (the service can't handle multiple symbol filters)
const hasMultipleHoldingFilters =
accessFilter?.holdings && accessFilter.holdings.length > 1;
const activityFilters = portfolioFilters.filter((filter) => {
// Always include ACCOUNT, ASSET_CLASS, TAG filters
if (
filter.type === 'ACCOUNT' ||
filter.type === 'ASSET_CLASS' ||
filter.type === 'TAG'
) {
return true;
}
// Include DATA_SOURCE and SYMBOL only if there's a single holding filter
if (
!hasMultipleHoldingFilters &&
(filter.type === 'DATA_SOURCE' || filter.type === 'SYMBOL')
) {
return true;
}
return false;
});
const { activities } = await this.orderService.getOrders({
filters: activityFilters.length > 0 ? activityFilters : undefined,
includeDrafts: false,
sortColumn: 'date',
sortDirection: 'desc',
take: 10,
take: hasMultipleHoldingFilters ? 1000 : 10, // Get more if we need to filter manually
types: [ActivityType.BUY, ActivityType.SELL],
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: user.id,
withExcludedAccountsAndActivities: false
});
// If multiple holdings, filter activities manually
let filteredActivities = activities;
if (hasMultipleHoldingFilters && accessFilter.holdings) {
filteredActivities = activities.filter((activity) => {
return accessFilter.holdings.some(
(holding) =>
activity.SymbolProfile.dataSource === holding.dataSource &&
activity.SymbolProfile.symbol === holding.symbol
);
});
}
// Take only the latest 10 activities after filtering
const latestActivitiesData = filteredActivities.slice(0, 10);
// Experimental
const latestActivities = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? []
: activities.map(
: latestActivitiesData.map(
({
currency,
date,
@ -128,12 +244,12 @@ export class PublicController {
});
const publicPortfolioResponse: PublicPortfolioResponse = {
alias: access.alias,
createdAt,
hasDetails,
latestActivities,
markets,
alias: access.alias,
holdings: {},
markets,
performance: {
'1d': {
relativeChange:
@ -151,19 +267,23 @@ export class PublicController {
};
const totalValue = getSum(
Object.values(holdings).map(({ currency, marketPrice, quantity }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
quantity * marketPrice,
currency,
this.request.user?.settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
)
);
})
Object.values(filteredHoldings).map(
({ currency, marketPrice, quantity }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
quantity * marketPrice,
currency,
this.request.user?.settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
)
);
}
)
).toNumber();
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
for (const [symbol, portfolioPosition] of Object.entries(
filteredHoldings
)) {
publicPortfolioResponse.holdings[symbol] = {
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,

221
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,8 +1,7 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import {
ChangeDetectionStrategy,
@ -13,6 +12,7 @@ import {
OnInit
} from '@angular/core';
import {
AbstractControl,
FormBuilder,
FormGroup,
FormsModule,
@ -28,9 +28,13 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { AccessPermission } from '@prisma/client';
import { StatusCodes } from 'http-status-codes';
import { EMPTY, Subject, catchError, takeUntil } from 'rxjs';
import { NotificationService } from '../../../core/notification/notification.service';
import { DataService } from '../../../services/data.service';
import { validateObjectForForm } from '../../../util/form.util';
import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@Component({
@ -54,13 +58,20 @@ export class GfCreateOrUpdateAccessDialogComponent
{
public accessForm: FormGroup;
public mode: 'create' | 'update';
public showFilterPanel = false;
// Datos para el filtro
public accounts: AccountWithPlatform[] = [];
public assetClasses: Filter[] = [];
public holdings: PortfolioPosition[] = [];
public tags: Filter[] = [];
private unsubscribeSubject = new Subject<void>();
public constructor(
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>,
private dataService: DataService,
private formBuilder: FormBuilder,
private notificationService: NotificationService
@ -73,14 +84,23 @@ export class GfCreateOrUpdateAccessDialogComponent
this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias],
filterAccount: [null],
filterAssetClass: [null],
filterHolding: [null],
filterTag: [null],
granteeUserId: [
this.data.access.grantee,
isPublic ? null : Validators.required
isPublic
? null
: [(control: AbstractControl) => Validators.required(control)]
],
permissions: [
this.data.access.permissions[0],
[(control: AbstractControl) => Validators.required(control)]
],
permissions: [this.data.access.permissions[0], Validators.required],
type: [
{ disabled: this.mode === 'update', value: this.data.access.type },
Validators.required
[(control: AbstractControl) => Validators.required(control)]
]
});
@ -89,17 +109,33 @@ export class GfCreateOrUpdateAccessDialogComponent
const permissionsControl = this.accessForm.get('permissions');
if (accessType === 'PRIVATE') {
granteeUserIdControl.setValidators(Validators.required);
granteeUserIdControl.setValidators([
(control: AbstractControl) => Validators.required(control)
]);
this.showFilterPanel = false;
// Limpiar los filtros
this.accessForm.get('filterAccount')?.setValue(null);
this.accessForm.get('filterAssetClass')?.setValue(null);
this.accessForm.get('filterHolding')?.setValue(null);
this.accessForm.get('filterTag')?.setValue(null);
} else {
granteeUserIdControl.clearValidators();
granteeUserIdControl.setValue(null);
permissionsControl.setValue(this.data.access.permissions[0]);
this.showFilterPanel = true;
this.loadFilterData();
}
granteeUserIdControl.updateValueAndValidity();
this.changeDetectorRef.markForCheck();
});
// Si ya es público al iniciar, mostrar el panel y cargar datos
if (isPublic) {
this.showFilterPanel = true;
this.loadFilterData();
}
}
public onCancel() {
@ -119,11 +155,154 @@ export class GfCreateOrUpdateAccessDialogComponent
this.unsubscribeSubject.complete();
}
private buildFilterObject():
| {
accountIds?: string[];
assetClasses?: string[];
holdings?: { dataSource: string; symbol: string }[];
tagIds?: string[];
}
| undefined {
const filterAccount = this.accessForm.get('filterAccount')?.value as
| string
| null;
const filterAssetClass = this.accessForm.get('filterAssetClass')?.value as
| string
| null;
const filterHolding = this.accessForm.get('filterHolding')?.value as
| string
| null;
const filterTag = this.accessForm.get('filterTag')?.value as string | null;
// Solo retornar filtro si hay al menos un campo con valor
if (!filterAccount && !filterAssetClass && !filterHolding && !filterTag) {
return undefined;
}
const filter: {
accountIds?: string[];
assetClasses?: string[];
holdings?: { dataSource: string; symbol: string }[];
tagIds?: string[];
} = {};
if (filterAccount) {
filter.accountIds = [filterAccount];
}
if (filterAssetClass) {
filter.assetClasses = [filterAssetClass];
}
if (filterHolding) {
// Buscar el holding seleccionado para obtener dataSource y symbol
const holding = this.holdings.find((h) => h.symbol === filterHolding);
if (holding) {
filter.holdings = [
{
dataSource: holding.dataSource,
symbol: holding.symbol
}
];
}
}
if (filterTag) {
filter.tagIds = [filterTag];
}
return filter;
}
private loadFilterData() {
const existingFilter = this.data.access.settings?.filter;
// Cargar cuentas
this.dataService
.fetchAccounts()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.accounts = response.accounts;
// Si existe un filtro de cuenta, establecerlo
if (existingFilter?.accountIds?.[0]) {
this.accessForm
.get('filterAccount')
?.setValue(existingFilter.accountIds[0]);
}
this.changeDetectorRef.markForCheck();
});
// Cargar holdings y asset classes
this.dataService
.fetchPortfolioDetails({})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
if (response.holdings) {
this.holdings = Object.values(response.holdings);
// Extraer asset classes únicas
const assetClassesSet = new Set<string>();
Object.values(response.holdings).forEach((holding) => {
if (holding.assetClass) {
assetClassesSet.add(holding.assetClass);
}
});
this.assetClasses = Array.from(assetClassesSet).map((ac) => ({
id: ac,
label: ac,
type: 'ASSET_CLASS' as const
}));
// Si existe un filtro de asset class, establecerlo
if (existingFilter?.assetClasses?.[0]) {
this.accessForm
.get('filterAssetClass')
?.setValue(existingFilter.assetClasses[0]);
}
// Si existe un filtro de holding, establecerlo
if (existingFilter?.holdings?.[0]?.symbol) {
this.accessForm
.get('filterHolding')
?.setValue(existingFilter.holdings[0].symbol);
}
}
this.changeDetectorRef.markForCheck();
});
// Cargar tags
this.dataService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.tags = response.map((tag) => ({
id: tag.id,
label: tag.name,
type: 'TAG' as const
}));
// Si existe un filtro de tag, establecerlo
if (existingFilter?.tagIds?.[0]) {
this.accessForm.get('filterTag')?.setValue(existingFilter.tagIds[0]);
}
this.changeDetectorRef.markForCheck();
});
}
private async createAccess() {
// Construir el objeto filter si estamos en modo PUBLIC
const filter = this.showFilterPanel ? this.buildFilterObject() : undefined;
const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
permissions: [this.accessForm.get('permissions').value]
alias: this.accessForm.get('alias')?.value as string,
filter: filter,
granteeUserId: this.accessForm.get('granteeUserId')?.value as string,
permissions: [
this.accessForm.get('permissions')?.value as AccessPermission
]
};
try {
@ -136,8 +315,8 @@ export class GfCreateOrUpdateAccessDialogComponent
this.dataService
.postAccess(access)
.pipe(
catchError((error) => {
if (error.status === StatusCodes.BAD_REQUEST) {
catchError((error: { status?: number }) => {
if (error.status === (StatusCodes.BAD_REQUEST as number)) {
this.notificationService.alert({
title: $localize`Oops! Could not grant access.`
});
@ -156,11 +335,17 @@ export class GfCreateOrUpdateAccessDialogComponent
}
private async updateAccess() {
// Construir el objeto filter si estamos en modo PUBLIC
const filter = this.showFilterPanel ? this.buildFilterObject() : undefined;
const access: UpdateAccessDto = {
alias: this.accessForm.get('alias').value,
granteeUserId: this.accessForm.get('granteeUserId').value,
alias: this.accessForm.get('alias')?.value as string,
filter: filter,
granteeUserId: this.accessForm.get('granteeUserId')?.value as string,
id: this.data.access.id,
permissions: [this.accessForm.get('permissions').value]
permissions: [
this.accessForm.get('permissions')?.value as AccessPermission
]
};
try {
@ -173,8 +358,8 @@ export class GfCreateOrUpdateAccessDialogComponent
this.dataService
.putAccess(access)
.pipe(
catchError(({ status }) => {
if (status.status === StatusCodes.BAD_REQUEST) {
catchError((error: { status?: number }) => {
if (error.status === (StatusCodes.BAD_REQUEST as number)) {
this.notificationService.alert({
title: $localize`Oops! Could not update access.`
});

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

@ -59,6 +59,80 @@
</mat-form-field>
</div>
}
@if (showFilterPanel) {
<div class="mt-4">
<h2 class="mb-3" i18n style="font-size: 1.125rem; font-weight: 500">
Filter Settings (Optional)
</h2>
<p class="text-muted mb-3" i18n style="font-size: 0.875rem">
Configure which accounts, holdings, tags, and asset classes will be
visible in this public access. Leave empty to show all data.
</p>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Account</mat-label>
<mat-select formControlName="filterAccount">
<mat-option [value]="null" />
@for (account of accounts; track account.id) {
<mat-option [value]="account.id">
{{ account.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Holding</mat-label>
<mat-select formControlName="filterHolding">
<mat-option [value]="null" />
@for (holding of holdings; track holding.symbol) {
<mat-option [value]="holding.symbol">
<div class="line-height-1 text-truncate">
<span
><b>{{ holding.name }}</b></span
>
<br />
<small class="text-muted"
>{{ holding.symbol }} · {{ holding.currency }}</small
>
</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tag</mat-label>
<mat-select formControlName="filterTag">
<mat-option [value]="null" />
@for (tag of tags; track tag.id) {
<mat-option [value]="tag.id">{{ tag.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="filterAssetClass">
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass.id) {
<mat-option [value]="assetClass.id">{{
assetClass.label
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</div>
<div class="justify-content-end" mat-dialog-actions>
<button mat-button type="button" (click)="onCancel()">

6
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.scss

@ -4,4 +4,10 @@
.mat-mdc-dialog-content {
max-height: unset;
}
ion-icon {
&.rotate-90 {
transform: rotate(90deg);
}
}
}

15
apps/client/src/app/components/user-account-access/user-account-access.component.ts

@ -17,7 +17,12 @@ import {
OnDestroy,
OnInit
} from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import {
AbstractControl,
FormBuilder,
ReactiveFormsModule,
Validators
} from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -61,7 +66,10 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
public hasPermissionToUpdateOwnAccessToken: boolean;
public isAccessTokenHidden = true;
public updateOwnAccessTokenForm = this.formBuilder.group({
accessToken: ['', Validators.required]
accessToken: [
'',
[(control: AbstractControl) => Validators.required(control)]
]
});
public user: User;
@ -117,7 +125,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
if (params['createDialog']) {
this.openCreateAccessDialog();
} else if (params['editDialog'] && params['accessId']) {
this.openUpdateAccessDialog(params['accessId']);
this.openUpdateAccessDialog(params['accessId'] as string);
}
});
@ -235,6 +243,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
grantee: access.grantee === 'Public' ? null : access.grantee,
id: access.id,
permissions: access.permissions,
settings: access.settings,
type: access.type
}
},

12
libs/common/src/lib/interfaces/access.interface.ts

@ -2,10 +2,22 @@ import { AccessType } from '@ghostfolio/common/types';
import { AccessPermission } from '@prisma/client';
export interface AccessFilter {
accountIds?: string[];
assetClasses?: string[];
holdings?: { dataSource: string; symbol: string }[];
tagIds?: string[];
}
export interface AccessSettings {
filter?: AccessFilter;
}
export interface Access {
alias?: string;
grantee?: string;
id: string;
permissions: AccessPermission[];
settings?: AccessSettings;
type: AccessType;
}

4
libs/common/src/lib/interfaces/index.ts

@ -1,4 +1,4 @@
import type { Access } from './access.interface';
import type { Access, AccessFilter, AccessSettings } from './access.interface';
import type { AccountBalance } from './account-balance.interface';
import type { AdminData } from './admin-data.interface';
import type { AdminJobs } from './admin-jobs.interface';
@ -81,6 +81,8 @@ import type { XRayRulesSettings } from './x-ray-rules-settings.interface';
export {
Access,
AccessFilter,
AccessSettings,
AccessTokenResponse,
AccountBalance,
AccountBalancesResponse,

Loading…
Cancel
Save