Germán Martín 2 days 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 a close holding button to the holding detail dialog
- Added the _Sponsors_ section to the about page - Added the _Sponsors_ section to the about page
- Extended the user detail dialog in the users section of the admin control panel - 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 ### 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 { Access as AccessModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccessSettings } from './access-settings.interface';
import { AccessService } from './access.service'; import { AccessService } from './access.service';
import { CreateAccessDto } from './create-access.dto'; import { CreateAccessDto } from './create-access.dto';
import { UpdateAccessDto } from './update-access.dto'; import { UpdateAccessDto } from './update-access.dto';
@ -34,40 +35,6 @@ export class AccessController {
@Inject(REQUEST) private readonly request: RequestWithUser @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) @HasPermission(permissions.createAccess)
@Post() @Post()
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -85,12 +52,17 @@ export class AccessController {
} }
try { try {
const settings: AccessSettings = data.filter
? { filter: data.filter }
: {};
return this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: undefined, : undefined,
permissions: data.permissions, permissions: data.permissions,
settings: settings as any,
user: { connect: { id: this.request.user.id } } user: { connect: { id: this.request.user.id } }
}); });
} catch { } 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) @HasPermission(permissions.updateAccess)
@Put(':id') @Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -152,13 +166,18 @@ export class AccessController {
} }
try { try {
const settings: AccessSettings = data.filter
? { filter: data.filter }
: {};
return this.accessService.updateAccess({ return this.accessService.updateAccess({
data: { data: {
alias: data.alias, alias: data.alias,
granteeUser: data.granteeUserId granteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }
: { disconnect: true }, : { disconnect: true },
permissions: data.permissions permissions: data.permissions,
settings: settings as any
}, },
where: { id } 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 { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
@ -6,6 +8,9 @@ export class CreateAccessDto {
@IsString() @IsString()
alias?: string; alias?: string;
@IsOptional()
filter?: AccessFilter;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
granteeUserId?: string; 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 { AccessPermission } from '@prisma/client';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
@ -6,6 +8,9 @@ export class UpdateAccessDto {
@IsString() @IsString()
alias?: string; alias?: string;
@IsOptional()
filter?: AccessFilter;
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
granteeUserId?: string; 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getSum } from '@ghostfolio/common/helper'; 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 type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -20,7 +24,7 @@ import {
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; 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 { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -61,6 +65,62 @@ export class PublicController {
hasDetails = user.subscription.type === 'Premium'; 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 [ const [
{ createdAt, holdings, markets }, { createdAt, holdings, markets },
{ performance: performance1d }, { performance: performance1d },
@ -68,6 +128,7 @@ export class PublicController {
{ performance: performanceYtd } { performance: performanceYtd }
] = await Promise.all([ ] = await Promise.all([
this.portfolioService.getDetails({ this.portfolioService.getDetails({
filters: portfolioFilters.length > 0 ? portfolioFilters : undefined,
impersonationId: access.userId, impersonationId: access.userId,
userId: user.id, userId: user.id,
withMarkets: true withMarkets: true
@ -75,29 +136,84 @@ export class PublicController {
...['1d', 'max', 'ytd'].map((dateRange) => { ...['1d', 'max', 'ytd'].map((dateRange) => {
return this.portfolioService.getPerformance({ return this.portfolioService.getPerformance({
dateRange, dateRange,
filters: portfolioFilters.length > 0 ? portfolioFilters : undefined,
impersonationId: undefined, impersonationId: undefined,
userId: user.id 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({ const { activities } = await this.orderService.getOrders({
filters: activityFilters.length > 0 ? activityFilters : undefined,
includeDrafts: false, includeDrafts: false,
sortColumn: 'date', sortColumn: 'date',
sortDirection: 'desc', sortDirection: 'desc',
take: 10, take: hasMultipleHoldingFilters ? 1000 : 10, // Get more if we need to filter manually
types: [ActivityType.BUY, ActivityType.SELL], types: [ActivityType.BUY, ActivityType.SELL],
userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY,
userId: user.id, userId: user.id,
withExcludedAccountsAndActivities: false 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 // Experimental
const latestActivities = this.configurationService.get( const latestActivities = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION' 'ENABLE_FEATURE_SUBSCRIPTION'
) )
? [] ? []
: activities.map( : latestActivitiesData.map(
({ ({
currency, currency,
date, date,
@ -128,12 +244,12 @@ export class PublicController {
}); });
const publicPortfolioResponse: PublicPortfolioResponse = { const publicPortfolioResponse: PublicPortfolioResponse = {
alias: access.alias,
createdAt, createdAt,
hasDetails, hasDetails,
latestActivities, latestActivities,
markets,
alias: access.alias,
holdings: {}, holdings: {},
markets,
performance: { performance: {
'1d': { '1d': {
relativeChange: relativeChange:
@ -151,19 +267,23 @@ export class PublicController {
}; };
const totalValue = getSum( const totalValue = getSum(
Object.values(holdings).map(({ currency, marketPrice, quantity }) => { Object.values(filteredHoldings).map(
return new Big( ({ currency, marketPrice, quantity }) => {
this.exchangeRateDataService.toCurrency( return new Big(
quantity * marketPrice, this.exchangeRateDataService.toCurrency(
currency, quantity * marketPrice,
this.request.user?.settings?.settings.baseCurrency ?? currency,
DEFAULT_CURRENCY this.request.user?.settings?.settings.baseCurrency ??
) DEFAULT_CURRENCY
); )
}) );
}
)
).toNumber(); ).toNumber();
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(
filteredHoldings
)) {
publicPortfolioResponse.holdings[symbol] = { publicPortfolioResponse.holdings[symbol] = {
allocationInPercentage: allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue, 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 { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto'; import { UpdateAccessDto } from '@ghostfolio/api/app/access/update-access.dto';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { DataService } from '@ghostfolio/client/services/data.service'; import { AccountWithPlatform } from '@ghostfolio/common/types';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -13,6 +12,7 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
import { import {
AbstractControl,
FormBuilder, FormBuilder,
FormGroup, FormGroup,
FormsModule, FormsModule,
@ -28,9 +28,13 @@ import {
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { AccessPermission } from '@prisma/client';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { EMPTY, Subject, catchError, takeUntil } from 'rxjs'; 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'; import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
@Component({ @Component({
@ -54,13 +58,20 @@ export class GfCreateOrUpdateAccessDialogComponent
{ {
public accessForm: FormGroup; public accessForm: FormGroup;
public mode: 'create' | 'update'; 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>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams, @Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<GfCreateOrUpdateAccessDialogComponent>,
private dataService: DataService, private dataService: DataService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService private notificationService: NotificationService
@ -73,14 +84,23 @@ export class GfCreateOrUpdateAccessDialogComponent
this.accessForm = this.formBuilder.group({ this.accessForm = this.formBuilder.group({
alias: [this.data.access.alias], alias: [this.data.access.alias],
filterAccount: [null],
filterAssetClass: [null],
filterHolding: [null],
filterTag: [null],
granteeUserId: [ granteeUserId: [
this.data.access.grantee, 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: [ type: [
{ disabled: this.mode === 'update', value: this.data.access.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'); const permissionsControl = this.accessForm.get('permissions');
if (accessType === 'PRIVATE') { 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 { } else {
granteeUserIdControl.clearValidators(); granteeUserIdControl.clearValidators();
granteeUserIdControl.setValue(null); granteeUserIdControl.setValue(null);
permissionsControl.setValue(this.data.access.permissions[0]); permissionsControl.setValue(this.data.access.permissions[0]);
this.showFilterPanel = true;
this.loadFilterData();
} }
granteeUserIdControl.updateValueAndValidity(); granteeUserIdControl.updateValueAndValidity();
this.changeDetectorRef.markForCheck(); 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() { public onCancel() {
@ -119,11 +155,154 @@ export class GfCreateOrUpdateAccessDialogComponent
this.unsubscribeSubject.complete(); 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() { private async createAccess() {
// Construir el objeto filter si estamos en modo PUBLIC
const filter = this.showFilterPanel ? this.buildFilterObject() : undefined;
const access: CreateAccessDto = { const access: CreateAccessDto = {
alias: this.accessForm.get('alias').value, alias: this.accessForm.get('alias')?.value as string,
granteeUserId: this.accessForm.get('granteeUserId').value, filter: filter,
permissions: [this.accessForm.get('permissions').value] granteeUserId: this.accessForm.get('granteeUserId')?.value as string,
permissions: [
this.accessForm.get('permissions')?.value as AccessPermission
]
}; };
try { try {
@ -136,8 +315,8 @@ export class GfCreateOrUpdateAccessDialogComponent
this.dataService this.dataService
.postAccess(access) .postAccess(access)
.pipe( .pipe(
catchError((error) => { catchError((error: { status?: number }) => {
if (error.status === StatusCodes.BAD_REQUEST) { if (error.status === (StatusCodes.BAD_REQUEST as number)) {
this.notificationService.alert({ this.notificationService.alert({
title: $localize`Oops! Could not grant access.` title: $localize`Oops! Could not grant access.`
}); });
@ -156,11 +335,17 @@ export class GfCreateOrUpdateAccessDialogComponent
} }
private async updateAccess() { private async updateAccess() {
// Construir el objeto filter si estamos en modo PUBLIC
const filter = this.showFilterPanel ? this.buildFilterObject() : undefined;
const access: UpdateAccessDto = { const access: UpdateAccessDto = {
alias: this.accessForm.get('alias').value, alias: this.accessForm.get('alias')?.value as string,
granteeUserId: this.accessForm.get('granteeUserId').value, filter: filter,
granteeUserId: this.accessForm.get('granteeUserId')?.value as string,
id: this.data.access.id, id: this.data.access.id,
permissions: [this.accessForm.get('permissions').value] permissions: [
this.accessForm.get('permissions')?.value as AccessPermission
]
}; };
try { try {
@ -173,8 +358,8 @@ export class GfCreateOrUpdateAccessDialogComponent
this.dataService this.dataService
.putAccess(access) .putAccess(access)
.pipe( .pipe(
catchError(({ status }) => { catchError((error: { status?: number }) => {
if (status.status === StatusCodes.BAD_REQUEST) { if (error.status === (StatusCodes.BAD_REQUEST as number)) {
this.notificationService.alert({ this.notificationService.alert({
title: $localize`Oops! Could not update access.` 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> </mat-form-field>
</div> </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>
<div class="justify-content-end" mat-dialog-actions> <div class="justify-content-end" mat-dialog-actions>
<button mat-button type="button" (click)="onCancel()"> <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 { .mat-mdc-dialog-content {
max-height: unset; 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, OnDestroy,
OnInit OnInit
} from '@angular/core'; } 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 { MatButtonModule } from '@angular/material/button';
import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
@ -61,7 +66,10 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
public hasPermissionToUpdateOwnAccessToken: boolean; public hasPermissionToUpdateOwnAccessToken: boolean;
public isAccessTokenHidden = true; public isAccessTokenHidden = true;
public updateOwnAccessTokenForm = this.formBuilder.group({ public updateOwnAccessTokenForm = this.formBuilder.group({
accessToken: ['', Validators.required] accessToken: [
'',
[(control: AbstractControl) => Validators.required(control)]
]
}); });
public user: User; public user: User;
@ -117,7 +125,7 @@ export class GfUserAccountAccessComponent implements OnDestroy, OnInit {
if (params['createDialog']) { if (params['createDialog']) {
this.openCreateAccessDialog(); this.openCreateAccessDialog();
} else if (params['editDialog'] && params['accessId']) { } 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, grantee: access.grantee === 'Public' ? null : access.grantee,
id: access.id, id: access.id,
permissions: access.permissions, permissions: access.permissions,
settings: access.settings,
type: access.type 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'; 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 { export interface Access {
alias?: string; alias?: string;
grantee?: string; grantee?: string;
id: string; id: string;
permissions: AccessPermission[]; permissions: AccessPermission[];
settings?: AccessSettings;
type: AccessType; 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 { AccountBalance } from './account-balance.interface';
import type { AdminData } from './admin-data.interface'; import type { AdminData } from './admin-data.interface';
import type { AdminJobs } from './admin-jobs.interface'; import type { AdminJobs } from './admin-jobs.interface';
@ -81,6 +81,8 @@ import type { XRayRulesSettings } from './x-ray-rules-settings.interface';
export { export {
Access, Access,
AccessFilter,
AccessSettings,
AccessTokenResponse, AccessTokenResponse,
AccountBalance, AccountBalance,
AccountBalancesResponse, AccountBalancesResponse,

Loading…
Cancel
Save