Browse Source

Merge branch 'main' into task/improve-mcp

pull/7786/head
Thomas Kaul 1 month ago
committed by GitHub
parent
commit
538bd40ff7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 17
      CHANGELOG.md
  2. 5
      apps/api/src/app/endpoints/ai/ai.controller.ts
  3. 2
      apps/api/src/app/endpoints/ai/ai.module.ts
  4. 6
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  5. 4
      apps/api/src/app/user/user.service.ts
  6. 30
      apps/api/src/dtos/filter.dto.ts
  7. 18
      apps/api/src/services/api/api.service.ts
  8. 2
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  9. 38
      apps/client/src/app/components/access-table/access-table.component.html
  10. 26
      apps/client/src/app/components/access-table/access-table.component.ts
  11. 2
      apps/client/src/app/components/home-holdings/home-holdings.html
  12. 42
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  13. 14
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html
  14. 84
      apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts
  15. 3
      apps/client/src/app/pages/accounts/account-dialog-host/types/account-dialog-mode.type.ts
  16. 86
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  17. 1
      apps/client/src/app/pages/accounts/accounts-page.html
  18. 9
      apps/client/src/app/pages/accounts/accounts-page.routes.ts
  19. 4
      apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts
  20. 52
      apps/client/src/locales/messages.it.xlf
  21. 20
      libs/common/src/lib/personal-finance-tools.ts
  22. 5
      libs/common/src/lib/routes/routes.ts
  23. 21
      libs/common/src/lib/scopes.ts
  24. 2
      libs/ui/src/lib/accounts-table/accounts-table.component.html
  25. 8
      libs/ui/src/lib/accounts-table/accounts-table.component.ts
  26. 4
      libs/ui/src/lib/holdings-table/holdings-table.component.ts
  27. 120
      package-lock.json
  28. 12
      package.json

17
CHANGELOG.md

@ -10,10 +10,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the server of the Model Context Protocol (MCP) (experimental)
- Hardened the validation of the query parameters (`accounts`, `assetClasses`, `dataSource` and `tags`) in the endpoints with filters
### Fixed
- Fixed the missing country mapping of _Congo (Dem. Rep. of the)_ and _Congo (Rep. of)_ in the _Financial Modeling Prep_ service
## 3.66.0 - 2026-09-03
### Changed
- Moved the details of the granted access from the table to the dialog on the access page (experimental)
- Restricted the _Restricted view and manage_ permission of the access to share the portfolio to the Model Context Protocol (MCP) (experimental)
- Migrated the transfer cash balance dialog to a dedicated route
- Improved the language localization for Italian (`it`)
- Upgraded `@rekog/mcp-nest` from version `2.0.0` to `2.0.2`
- Upgraded `prisma` from version `7.9.1` to `7.10.0`
### Fixed
- Fixed the loading state of the accounts table on the accounts page
- Fixed the loading state of the holdings table on the portfolio holdings page
## 3.65.0 - 2026-08-31

5
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,6 +1,7 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { FilterDto } from '@ghostfolio/api/dtos/filter.dto';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -12,7 +13,8 @@ import {
Inject,
Param,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -30,6 +32,7 @@ export class AiController {
@Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getPrompt(
@Param('mode') mode: AiPromptMode,
@Query()

2
apps/api/src/app/endpoints/ai/ai.module.ts

@ -7,6 +7,7 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -46,6 +47,7 @@ import { AiService } from './ai.service';
RedisCacheModule,
SymbolProfileModule,
TagModule,
TransformDataSourceInRequestModule,
UserModule
],
providers: [

6
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -64,8 +64,8 @@ export class GhostfolioMcpController {
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts: accountIds?.join(','),
filterByAssetClasses: assetClasses?.join(','),
filterByAccounts: accountIds,
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});
@ -111,7 +111,7 @@ export class GhostfolioMcpController {
}
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetClasses: assetClasses?.join(','),
filterByAssetClasses: assetClasses,
filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol
});

4
apps/api/src/app/user/user.service.ts

@ -275,7 +275,7 @@ export class UserService {
}
try {
const { isBlocked } = await this.throttlerStorage.increment(
const { totalHits } = await this.throttlerStorage.increment(
`${THROTTLE_DAILY_KEY}-${user.id}`,
THROTTLE_DAILY_TTL,
maxDailyRequests,
@ -283,7 +283,7 @@ export class UserService {
THROTTLE_DAILY_KEY
);
return isBlocked;
return totalHits > maxDailyRequests;
} catch (error) {
this.logger.error(error);

30
apps/api/src/dtos/filter.dto.ts

@ -1,23 +1,35 @@
import { IsOptional, IsString } from 'class-validator';
import { AssetClass, DataSource } from '@prisma/client';
import { Transform, TransformFnParams } from 'class-transformer';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { isString } from 'lodash';
export class FilterDto {
@IsOptional()
@IsString()
accounts?: string;
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
accounts?: string[];
@IsEnum(AssetClass, { each: true })
@IsOptional()
@IsString()
assetClasses?: string;
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
assetClasses?: AssetClass[];
@IsEnum(DataSource)
@IsOptional()
@IsString()
dataSource?: string;
dataSource?: DataSource;
@IsOptional()
@IsString()
symbol?: string;
@IsOptional()
@IsString()
tags?: string;
@IsUUID(undefined, { each: true })
@Transform(({ value }: TransformFnParams) => {
return isString(value) ? value.split(',') : value;
})
tags?: string[];
}

18
apps/api/src/services/api/api.service.ts

@ -14,23 +14,23 @@ export class ApiService {
filterBySymbol,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByAccounts?: string[];
filterByAssetClasses?: string[];
filterByAssetSubClasses?: string;
filterByDataSource?: string;
filterByHoldingType?: string;
filterBySearchQuery?: string;
filterBySymbol?: string;
filterByTags?: string;
filterByTags?: string[];
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const accountIds = filterByAccounts ?? [];
const assetClasses = filterByAssetClasses ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const dataSource = filterByDataSource;
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase();
const symbol = filterBySymbol;
const tagIds = filterByTags?.split(',') ?? [];
const tagIds = filterByTags ?? [];
const filters = [
...accountIds.map((accountId) => {
@ -96,11 +96,11 @@ export class ApiService {
userSettings: UserSettings;
}): Filter[] {
return this.buildFiltersFromQueryParams({
filterByAccounts: userSettings?.['filters.accounts']?.[0],
filterByAssetClasses: userSettings?.['filters.assetClasses']?.[0],
filterByAccounts: userSettings?.['filters.accounts'],
filterByAssetClasses: userSettings?.['filters.assetClasses'],
filterByDataSource: userSettings?.['filters.dataSource'],
filterBySymbol: userSettings?.['filters.symbol'],
filterByTags: userSettings?.['filters.tags']?.[0]
filterByTags: userSettings?.['filters.tags']
});
}
}

2
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -54,6 +54,8 @@ export class FinancialModelingPrepService
implements DataProviderInterface, OnModuleInit
{
private static countriesMapping = {
'Congo (Dem. Rep. of the)': 'CD',
'Congo (Rep. of)': 'CG',
'Czech Republic': 'CZ',
'Korea (the Republic of)': 'KR',
Macau: 'MO',

38
apps/client/src/app/components/access-table/access-table.component.html

@ -45,36 +45,6 @@
</td>
</ng-container>
<ng-container matColumnDef="details">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Details</th>
<td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
@if (element.type === 'PUBLIC') {
<div class="align-items-center d-flex">
<ion-icon class="mr-1" name="link-outline" />
<a target="_blank" [href]="getPublicUrl(element.id)">{{
getPublicUrl(element.id)
}}</a>
</div>
@if (user()?.settings?.isExperimentalFeatures) {
<div>
<code
>GET {{ baseUrl }}/api/v1/public/{{
element.id
}}/portfolio</code
>
</div>
}
} @else if (element.type === 'MCP' && hasPermissionToEnableMcp) {
<div>
<code>{{ baseUrl }}{{ mcpEndpoint }}</code>
</div>
<div>
<code>Authorization: Bearer {{ element.id }}</code>
</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell></th>
@ -113,14 +83,6 @@
</span>
</button>
}
@if (element.type === 'MCP' && hasPermissionToEnableMcp) {
<button mat-menu-item (click)="onCopyIdToClipboard(element.id)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="copy-outline" />
<span i18n>Copy identifier to clipboard</span>
</span>
</button>
}
@if (
(!isReceivedAccess() && user()?.settings?.isExperimentalFeatures) ||
element.type === 'PUBLIC' ||

26
apps/client/src/app/components/access-table/access-table.component.ts

@ -1,4 +1,3 @@
import { MCP_ENDPOINT } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces';
@ -32,7 +31,6 @@ import {
copyOutline,
createOutline,
ellipsisHorizontal,
linkOutline,
removeCircleOutline,
trashOutline
} from 'ionicons/icons';
@ -81,14 +79,7 @@ export class GfAccessTableComponent {
protected readonly dataSource = new MatTableDataSource<Access>();
protected readonly displayedColumns = computed(() => {
const columns = [
'alias',
'grantee',
'type',
'lastUsedAt',
'expiresAt',
'details'
];
const columns = ['alias', 'grantee', 'type', 'lastUsedAt', 'expiresAt'];
if (this.showActions()) {
columns.push('actions');
@ -109,8 +100,6 @@ export class GfAccessTableComponent {
return !this.accesses();
});
protected readonly mcpEndpoint = MCP_ENDPOINT;
private readonly clipboard = inject(Clipboard);
private readonly dataService = inject(DataService);
private readonly notificationService = inject(NotificationService);
@ -121,7 +110,6 @@ export class GfAccessTableComponent {
copyOutline,
createOutline,
ellipsisHorizontal,
linkOutline,
removeCircleOutline,
trashOutline
});
@ -142,18 +130,6 @@ export class GfAccessTableComponent {
return `${this.baseUrl}/${languageCode}/${publicRoutes.public.path}/${aId}`;
}
protected onCopyIdToClipboard(aId: string) {
this.clipboard.copy(aId);
this.snackBar.open(
'✅ ' + $localize`Identifier has been copied to the clipboard`,
undefined,
{
duration: ms('3 seconds')
}
);
}
protected onCopyUrlToClipboard(aId: string) {
this.clipboard.copy(this.getPublicUrl(aId));

2
apps/client/src/app/components/home-holdings/home-holdings.html

@ -37,7 +37,7 @@
}
<div [class.d-none]="holdingsViewMode !== 'TABLE'">
<gf-holdings-table
[holdings]="holdings ?? []"
[holdings]="holdings"
[locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
(holdingClicked)="onHoldingClicked($event)"
/>

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

@ -1,11 +1,12 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { DEFAULT_LOCALE, MCP_ENDPOINT } from '@ghostfolio/common/config';
import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos';
import { canApplyFiltersToAccess } from '@ghostfolio/common/helper';
import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import {
Scope,
canGrantRestrictedWriteAccess,
getAccessLevel,
getScopesOfAccess,
getScopesOfAccessLevel,
@ -26,6 +27,7 @@ import {
} from '@ghostfolio/ui/portfolio-filter-form';
import { DataService } from '@ghostfolio/ui/services';
import { JsonPipe } from '@angular/common';
import type { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
@ -68,6 +70,7 @@ import { CreateOrUpdateAccessDialogParams } from './interfaces/interfaces';
FormsModule,
GfAccessLevelIconComponent,
GfPortfolioFilterFormComponent,
JsonPipe,
MatButtonModule,
MatDatepickerModule,
MatDialogModule,
@ -87,6 +90,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
public tags: Filter[] = [];
protected accessForm: FormGroup;
protected readonly baseUrl = window.location.origin;
protected minExpiresAt: Date;
protected readonly mode: 'create' | 'update';
protected readonly today = startOfDay(new Date());
@ -125,6 +129,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return this.hasExperimentalFeatures && this.hasPermissionToEnableMcp;
}
public get canGrantRestrictedWriteAccess() {
return canGrantRestrictedWriteAccess({ type: this.accessType });
}
public get canGrantWriteAccess() {
return this.hasExperimentalFeatures;
}
@ -217,6 +225,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
this.loadHoldings();
}
protected get accessId() {
return this.data.access?.id;
}
protected get accessLevel(): AccessLevel {
return this.accessForm?.get('accessLevel')?.value as AccessLevel;
}
@ -229,10 +241,32 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
return this.accessType === 'PUBLIC';
}
protected get mcpConfiguration() {
return {
headers: {
Authorization: `Bearer ${this.accessId}`
},
type: 'http',
url: `${this.baseUrl}${MCP_ENDPOINT}`
};
}
protected get showExpiresAtErrorMessage() {
return this.accessForm?.get('expiresAt')?.invalid === true;
}
protected get showMcpDetails() {
return this.canGrantMcpAccess && this.isMcpAccess && this.mode === 'update';
}
protected get showPublicDetails() {
return (
this.hasExperimentalFeatures &&
this.isPublicAccess &&
this.mode === 'update'
);
}
protected onCancel() {
this.dialogRef.close();
}
@ -245,6 +279,10 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}
}
private get isMcpAccess() {
return this.accessType === 'MCP';
}
private async createAccess() {
const filters = this.getFilters();
@ -338,7 +376,7 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit {
}
private async updateAccess() {
const accessId = this.data.access?.id;
const accessId = this.accessId;
if (!accessId) {
return;

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

@ -82,7 +82,7 @@
<mat-option value="READ_RESTRICTED">
<gf-access-level-icon accessLevel="READ_RESTRICTED" />
</mat-option>
@if (!isPublicAccess) {
@if (canGrantRestrictedWriteAccess) {
<mat-option
value="CREATE_READ_RESTRICTED_UPDATE_DELETE"
[disabled]="!canGrantWriteAccess"
@ -133,6 +133,18 @@
[tags]="tags"
/>
}
@if (showMcpDetails || showPublicDetails) {
<h2 class="h6" i18n>Details</h2>
@if (showMcpDetails) {
<pre class="m-0"><code>{{ mcpConfiguration | json }}</code></pre>
} @else if (showPublicDetails) {
<div>
<code class="text-break"
>GET {{ baseUrl }}/api/v1/public/{{ accessId }}/portfolio</code
>
</div>
}
}
</div>
<div class="justify-content-end" mat-dialog-actions>
<button mat-button type="button" (click)="onCancel()">

84
apps/client/src/app/pages/accounts/account-dialog-host/account-dialog-host.component.ts

@ -5,11 +5,17 @@ import {
} from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateAccountDto, UpdateAccountDto } from '@ghostfolio/common/dtos';
import {
CreateAccountDto,
TransferBalanceDto,
UpdateAccountDto
} from '@ghostfolio/common/dtos';
import { AccountResponse, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { Scope, hasScope, scopes } from '@ghostfolio/common/scopes';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import {
@ -36,6 +42,8 @@ import {
import { GfCreateOrUpdateAccountDialogComponent } from '../create-or-update-account-dialog/create-or-update-account-dialog.component';
import { CreateOrUpdateAccountDialogParams } from '../create-or-update-account-dialog/interfaces/interfaces';
import { TransferBalanceDialogParams } from '../transfer-balance/interfaces/interfaces';
import { GfTransferBalanceDialogComponent } from '../transfer-balance/transfer-balance-dialog.component';
import { AccountDialogMode } from './types/account-dialog-mode.type';
@Component({
@ -45,7 +53,9 @@ import { AccountDialogMode } from './types/account-dialog-mode.type';
})
export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
private dialogRef: MatDialogRef<
GfAccountDetailDialogComponent | GfCreateOrUpdateAccountDialogComponent
| GfAccountDetailDialogComponent
| GfCreateOrUpdateAccountDialogComponent
| GfTransferBalanceDialogComponent
>;
private readonly deviceType = computed(() => {
@ -61,6 +71,7 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
@ -108,6 +119,24 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
return;
}
if (mode === 'transferCashBalance') {
const accounts = user?.accounts ?? [];
if (
accounts.length < 2 ||
!hasPermission(user?.permissions, permissions.updateAccount) ||
this.isWriteRestricted(user, scopes.accountUpdate)
) {
this.navigateBack();
return;
}
this.openTransferBalanceDialog({ accounts });
return;
}
if (mode === 'update') {
if (
!account ||
@ -298,4 +327,55 @@ export class GfAccountDialogHostComponent implements OnDestroy, OnInit {
});
});
}
private openTransferBalanceDialog({
accounts
}: {
accounts: AccountWithPlatform[];
}) {
const dialogRef = this.dialog.open<
GfTransferBalanceDialogComponent,
TransferBalanceDialogParams,
TransferBalanceDto | null
>(GfTransferBalanceDialogComponent, {
data: {
accounts
},
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
this.dialogRef = dialogRef;
dialogRef
.afterClosed()
.pipe(takeUntil(this.dialogClosed), takeUntilDestroyed(this.destroyRef))
.subscribe((result) => {
if (!result) {
this.navigateBack();
return;
}
this.dataService
.transferAccountBalance(result)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
error: () => {
this.notificationService.alert({
title: $localize`Oops, cash balance transfer has failed.`
});
this.navigateBack();
},
next: () => {
// Deliberately not bound to the destroy reference: navigating
// back destroys this component and the refreshed user is what
// makes the accounts page reload its data
this.userService.get(true).subscribe();
this.navigateBack();
}
});
});
}
}

3
apps/client/src/app/pages/accounts/account-dialog-host/types/account-dialog-mode.type.ts

@ -1 +1,2 @@
export type AccountDialogMode = 'create' | 'detail' | 'update';
export type AccountDialogMode =
'create' | 'detail' | 'transferCashBalance' | 'update';

86
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -1,7 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { AccountsResponse, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
@ -9,27 +8,19 @@ import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { AccountWithValue } from '@ghostfolio/common/types';
import { GfAccountsTableComponent } from '@ghostfolio/ui/accounts-table';
import { GfFabComponent } from '@ghostfolio/ui/fab';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
computed,
DestroyRef,
inject,
OnInit
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
import { filter, of, switchMap, tap } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { TransferBalanceDialogParams } from './transfer-balance/interfaces/interfaces';
import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-balance-dialog.component';
import { Router, RouterModule } from '@angular/router';
import { filter, switchMap, tap } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -54,33 +45,15 @@ export class GfAccountsPageComponent implements OnInit {
private hasImpersonationId: boolean;
private readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType
);
private readonly changeDetectorRef = inject(ChangeDetectorRef);
private readonly dataService = inject(DataService);
private readonly destroyRef = inject(DestroyRef);
private readonly deviceDetectorService = inject(DeviceDetectorService);
private readonly dialog = inject(MatDialog);
private readonly impersonationStorageService = inject(
ImpersonationStorageService
);
private readonly notificationService = inject(NotificationService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly userService = inject(UserService);
public constructor() {
this.route.queryParams
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
if (params['transferBalanceDialog']) {
this.openTransferBalanceDialog();
}
});
}
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
@ -135,67 +108,12 @@ export class GfAccountsPageComponent implements OnInit {
});
}
protected onTransferBalance() {
this.router.navigate([], {
queryParams: { transferBalanceDialog: true }
});
}
private fetchAccounts() {
return this.dataService.fetchAccounts({
filters: this.userService.getFilters()
});
}
private openTransferBalanceDialog() {
const dialogRef = this.dialog.open<
GfTransferBalanceDialogComponent,
TransferBalanceDialogParams
>(GfTransferBalanceDialogComponent, {
data: {
accounts: this.user?.accounts
},
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((data: any) => {
if (data) {
this.reset();
const { accountIdFrom, accountIdTo, balance }: TransferBalanceDto =
data?.account;
this.dataService
.transferAccountBalance({
accountIdFrom,
accountIdTo,
balance
})
.pipe(
catchError(() => {
this.notificationService.alert({
title: $localize`Oops, cash balance transfer has failed.`
});
return of(undefined);
}),
switchMap(() => this.fetchAccounts()),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((response) => {
this.updateAccounts(response);
});
this.changeDetectorRef.markForCheck();
}
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private reset() {
this.accounts = undefined;
this.activitiesCount = 0;

1
apps/client/src/app/pages/accounts/accounts-page.html

@ -16,7 +16,6 @@
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency"
(accountDeleted)="onDeleteAccount($event)"
(transferBalance)="onTransferBalance()"
/>
</div>
</div>

9
apps/client/src/app/pages/accounts/accounts-page.routes.ts

@ -6,7 +6,8 @@ import { Routes } from '@angular/router';
import { GfAccountDialogHostComponent } from './account-dialog-host/account-dialog-host.component';
import { GfAccountsPageComponent } from './accounts-page.component';
const { create, detail, update } = internalRoutes.accounts.subRoutes;
const { create, detail, transferCashBalance, update } =
internalRoutes.accounts.subRoutes;
export const routes: Routes = [
{
@ -18,6 +19,12 @@ export const routes: Routes = [
path: create.path,
title: create.title
},
{
component: GfAccountDialogHostComponent,
data: { mode: 'transferCashBalance' },
path: transferCashBalance.path,
title: transferCashBalance.title
},
{
children: [
{

4
apps/client/src/app/pages/accounts/transfer-balance/transfer-balance-dialog.component.ts

@ -99,13 +99,13 @@ export class GfTransferBalanceDialogComponent {
}
protected onSubmit() {
const account: TransferBalanceDto = {
const transferBalance: TransferBalanceDto = {
accountIdFrom: this.transferBalanceForm.controls.fromAccount.value ?? '',
accountIdTo: this.transferBalanceForm.controls.toAccount.value ?? '',
balance: Number(this.transferBalanceForm.controls.balance.value)
};
this.dialogRef.close({ account });
this.dialogRef.close(transferBalance);
}
private getAccountById(aId: string | null) {

52
apps/client/src/locales/messages.it.xlf

@ -40,7 +40,7 @@
</trans-unit>
<trans-unit id="9153520284278555926" datatype="html">
<source>please</source>
<target state="new">please</target>
<target state="translated">per favore</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/pricing/pricing-page.html</context>
<context context-type="linenumber">333</context>
@ -92,7 +92,7 @@
</trans-unit>
<trans-unit id="1351814922314683865" datatype="html">
<source>with</source>
<target state="new">with</target>
<target state="translated">con</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html</context>
<context context-type="linenumber">87</context>
@ -100,7 +100,7 @@
</trans-unit>
<trans-unit id="8264632476919693099" datatype="html">
<source>Any person who has the link can view the data.</source>
<target state="new">Any person who has the link can view the data.</target>
<target state="translated">Chiunque abbia il link può visualizzare i dati.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.html</context>
<context context-type="linenumber">42</context>
@ -328,7 +328,7 @@
</trans-unit>
<trans-unit id="8282940047848889809" datatype="html">
<source>Paid</source>
<target state="new">Paid</target>
<target state="translated">Pagamento effettuato</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts</context>
<context context-type="linenumber">136</context>
@ -368,7 +368,7 @@
</trans-unit>
<trans-unit id="4467323362722952678" datatype="html">
<source>Unknown</source>
<target state="new">Unknown</target>
<target state="translated">Sconosciuto</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">89</context>
@ -384,7 +384,7 @@
</trans-unit>
<trans-unit id="4472298507300372923" datatype="html">
<source>View and manage</source>
<target state="new">View and manage</target>
<target state="translated">Visualizza e gestisci</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/access-level-icon/access-level-icon.component.html</context>
<context context-type="linenumber">9</context>
@ -420,7 +420,7 @@
</trans-unit>
<trans-unit id="5611965261696422586" datatype="html">
<source>and is driven by the efforts of its <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio/graphs/contributors&quot; i18n-title title=&quot;Contributors to Ghostfolio&quot; &gt;"/>contributors<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/></source>
<target state="new">and is driven by the efforts of its <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio/graphs/contributors&quot; i18n-title title=&quot;Contributors to Ghostfolio&quot; &gt;"/>contributors<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/></target>
<target state="translated">ed è sostenuto dall’impegno dei suoi <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://github.com/ghostfolio/ghostfolio/graphs/contributors&quot; i18n-title title=&quot;Contributors to Ghostfolio&quot; &gt;"/>contributori<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a &gt;"/></target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context>
<context context-type="linenumber">50</context>
@ -428,7 +428,7 @@
</trans-unit>
<trans-unit id="3060494754215793943" datatype="html">
<source>Asset Profiles</source>
<target state="translated">Profilo dell’asset</target>
<target state="translated">Profili degli asset</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-settings/admin-settings.component.html</context>
<context context-type="linenumber">131</context>
@ -492,7 +492,7 @@
</trans-unit>
<trans-unit id="3973560112150137298" datatype="html">
<source>Find an account...</source>
<target state="new">Find an account...</target>
<target state="translated">Trova un account...</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/assistant/assistant.component.ts</context>
<context context-type="linenumber">448</context>
@ -528,7 +528,7 @@
</trans-unit>
<trans-unit id="8410000928786197012" datatype="html">
<source>Watch the Ghostfol.io Trailer on YouTube</source>
<target state="new">Watch the Ghostfol.io Trailer on YouTube</target>
<target state="translated">Guarda il trailer di Ghostfol.io su YouTube</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/landing/landing-page.html</context>
<context context-type="linenumber">19</context>
@ -644,7 +644,7 @@
</trans-unit>
<trans-unit id="6249687295240367729" datatype="html">
<source>Explore our guides to help you get started with investing and managing your finances.</source>
<target state="new">Explore our guides to help you get started with investing and managing your finances.</target>
<target state="translated">Esplora le nostre guide per iniziare a investire e a gestire le tue finanze.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/overview/resources-overview.component.ts</context>
<context context-type="linenumber">21</context>
@ -668,7 +668,7 @@
</trans-unit>
<trans-unit id="8155924822307560615" datatype="html">
<source>Update Account</source>
<target state="new">Update Account</target>
<target state="translated">Aggiorna account</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
<context context-type="linenumber">101</context>
@ -740,7 +740,7 @@
</trans-unit>
<trans-unit id="2395205455607568422" datatype="html">
<source>No auto-renewal on membership.</source>
<target state="new">No auto-renewal on membership.</target>
<target state="translated">Nessun rinnovo automatico dell’abbonamento.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/user-account-membership/user-account-membership.html</context>
<context context-type="linenumber">77</context>
@ -820,7 +820,7 @@
</trans-unit>
<trans-unit id="2047393478951255414" datatype="html">
<source>Creation</source>
<target state="new">Creation</target>
<target state="translated">Creazione</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-overview/admin-overview.html</context>
<context context-type="linenumber">191</context>
@ -876,7 +876,7 @@
</trans-unit>
<trans-unit id="504959340312049745" datatype="html">
<source>Split Ratio</source>
<target state="new">Split Ratio</target>
<target state="translated">Rapporto di frazionamento</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html</context>
<context context-type="linenumber">532</context>
@ -884,7 +884,7 @@
</trans-unit>
<trans-unit id="1022809987579838138" datatype="html">
<source>Last Used</source>
<target state="new">Last Used</target>
<target state="translated">Ultimo utilizzo</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.html</context>
<context context-type="linenumber">31</context>
@ -892,7 +892,7 @@
</trans-unit>
<trans-unit id="1029620928611278483" datatype="html">
<source>An error occurred while merging this asset profile into <x id="PH" equiv-text="symbol"/> (<x id="PH_1" equiv-text="dataSource"/>).</source>
<target state="new">An error occurred while merging this asset profile into <x id="PH" equiv-text="symbol"/> (<x id="PH_1" equiv-text="dataSource"/>).</target>
<target state="translated">Si è verificato un errore durante l’unione di questo profilo di asset in <x id="PH" equiv-text="symbol"/> (<x id="PH_1" equiv-text="dataSource"/>).</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts</context>
<context context-type="linenumber">942</context>
@ -936,7 +936,7 @@
</trans-unit>
<trans-unit id="6623155069376115284" datatype="html">
<source>Find quick answers to commonly asked questions about Ghostfolio in our Frequently Asked Questions (FAQ) section.</source>
<target state="new">Find quick answers to commonly asked questions about Ghostfolio in our Frequently Asked Questions (FAQ) section.</target>
<target state="translated">Trova risposte rapide alle domande più frequenti su Ghostfolio nella nostra sezione FAQ.</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/resources/overview/resources-overview.component.ts</context>
<context context-type="linenumber">16</context>
@ -992,7 +992,7 @@
</trans-unit>
<trans-unit id="273949409065292025" datatype="html">
<source>Energy</source>
<target state="new">Energy</target>
<target state="translated">Energia</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">96</context>
@ -1220,7 +1220,7 @@
</trans-unit>
<trans-unit id="8793726805339626615" datatype="html">
<source>Ghostfolio in Numbers: Monthly Active Users (MAU)</source>
<target state="new">Ghostfolio in Numbers: Monthly Active Users (MAU)</target>
<target state="translated">Ghostfolio in cifre: utenti attivi mensili (MAU)</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/landing/landing-page.html</context>
<context context-type="linenumber">63</context>
@ -1240,7 +1240,7 @@
</trans-unit>
<trans-unit id="366169681580494481" datatype="html">
<source>Performance with currency effect</source>
<target state="new">Performance with currency effect</target>
<target state="translated">Performance con effetto valuta</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/portfolio/analysis/analysis-page.html</context>
<context context-type="linenumber">125</context>
@ -1260,7 +1260,7 @@
</trans-unit>
<trans-unit id="3676123092874008298" datatype="html">
<source>Identifier has been copied to the clipboard</source>
<target state="new">Identifier has been copied to the clipboard</target>
<target state="translated">Identificativo copiato negli appunti</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/components/access-table/access-table.component.ts</context>
<context context-type="linenumber">149</context>
@ -1340,7 +1340,7 @@
</trans-unit>
<trans-unit id="1259421956660976189" datatype="html">
<source>Add Account</source>
<target state="new">Add Account</target>
<target state="translated">Aggiungi account</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/common/src/lib/routes/routes.ts</context>
<context context-type="linenumber">87</context>
@ -1356,7 +1356,7 @@
</trans-unit>
<trans-unit id="4432485267082955422" datatype="html">
<source>Consumer Defensive</source>
<target state="new">Consumer Defensive</target>
<target state="translated">Beni di consumo difensivi</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">95</context>
@ -1408,7 +1408,7 @@
</trans-unit>
<trans-unit id="3848479214898655176" datatype="html">
<source>Utilities</source>
<target state="new">Utilities</target>
<target state="translated">Servizi di pubblica utilità</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/i18n.ts</context>
<context context-type="linenumber">103</context>
@ -9414,7 +9414,7 @@
</trans-unit>
<trans-unit id="1184917049575486230" datatype="html">
<source>Support Ghostfolio</source>
<target state="new">Support Ghostfolio</target>
<target state="translated">Supporta Ghostfolio</target>
<context-group purpose="location">
<context context-type="sourcefile">apps/client/src/app/pages/about/overview/about-overview-page.html</context>
<context context-type="linenumber">170</context>

20
libs/common/src/lib/personal-finance-tools.ts

@ -1590,6 +1590,26 @@ export const personalFinanceTools: Product[] = [
slogan: 'The Intelligent Portfolio Tracker',
url: 'https://www.navexa.com'
},
{
categories: [
'BUDGETING',
'CRYPTOCURRENCY',
'ETF_TRACKING',
'FINANCIAL_PLANNING',
'NET_WORTH_TRACKING',
'STOCK_TRACKING'
],
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'neontra',
languages: ['English'],
name: 'Neontra',
origin: 'CA',
platforms: ['ANDROID', 'IOS', 'WEB'],
pricingPerYear: '$75',
slogan: 'Everything you own. Everything you owe. Everything in one place.',
url: 'https://neontra.com'
},
{
categories: ['INVESTMENT_RESEARCH', 'STOCK_TRACKING'],
hasFreePlan: true,

5
libs/common/src/lib/routes/routes.ts

@ -93,6 +93,11 @@ export const internalRoutes = {
},
title: $localize`Account`
},
transferCashBalance: {
path: 'transfer-cash-balance',
routerLink: ['/accounts', 'transfer-cash-balance'],
title: $localize`Transfer Cash Balance`
},
update: {
path: 'update',
routerLink: (aAccountId: string) => {

21
libs/common/src/lib/scopes.ts

@ -64,7 +64,9 @@ export const SCOPES_OF_READ_RESTRICTED_ACCESS: readonly Scope[] =
/**
* Maximum scopes per access type. The scopes stored on an access are
* intersected with it, hence a scope which the type does not permit stays
* ineffective even if it is stored.
* ineffective even if it is stored. A type which cannot grant the restricted
* write access drops the write scopes in addition, unless the access reads the
* monetary values.
*/
const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = {
MCP: [...SCOPES_OF_READ_RESTRICTED_ACCESS, scopes.activityCreate],
@ -72,6 +74,14 @@ const SCOPES_OF_TYPE: Record<AccessType, readonly Scope[]> = {
PUBLIC: SCOPES_OF_PUBLIC_ACCESS
};
/**
* Access types which combine a write scope with the restricted read access,
* because their tools change data without exposing the monetary values
*/
export function canGrantRestrictedWriteAccess({ type }: { type: AccessType }) {
return type === 'MCP';
}
/**
* Access level which the scopes of an access grant
*/
@ -96,9 +106,16 @@ export function getScopesOfAccess({
}): string[] {
const scopesToEvaluate = scopesOfAccess ?? [];
const permitsWriteAccess =
canGrantRestrictedWriteAccess({ type }) ||
hasScope(scopesToEvaluate, scopes.portfolioReadValues);
// An unknown scope is dropped
return SCOPES_OF_TYPE[type].filter((scope) => {
return scopesToEvaluate.includes(scope);
return (
scopesToEvaluate.includes(scope) &&
(permitsWriteAccess || !SCOPES_OF_WRITE_ACCESS.includes(scope))
);
});
}

2
libs/ui/src/lib/accounts-table/accounts-table.component.html

@ -4,7 +4,7 @@
class="align-items-center d-flex"
mat-stroked-button
[disabled]="dataSource?.data.length < 2"
(click)="onTransferBalance()"
[routerLink]="transferCashBalanceRouterLink"
>
<ion-icon class="mr-2" name="arrow-redo-outline" />
<span><ng-container i18n>Transfer Cash Balance</ng-container>...</span>

8
libs/ui/src/lib/accounts-table/accounts-table.component.ts

@ -75,7 +75,6 @@ export class GfAccountsTableComponent {
public readonly totalValueInBaseCurrency = input<number>();
public readonly accountDeleted = output<string>();
public readonly transferBalance = output<void>();
public readonly sort = viewChild.required(MatSort);
@ -141,6 +140,9 @@ export class GfAccountsTableComponent {
protected readonly isLoading = computed(() => !this.accounts());
protected readonly transferCashBalanceRouterLink =
internalRoutes.accounts.subRoutes.transferCashBalance.routerLink;
private readonly notificationService = inject(NotificationService);
private readonly router = inject(Router);
@ -197,8 +199,4 @@ export class GfAccountsTableComponent {
title: aComment
});
}
protected onTransferBalance() {
this.transferBalance.emit();
}
}

4
libs/ui/src/lib/holdings-table/holdings-table.component.ts

@ -50,7 +50,7 @@ export class GfHoldingsTableComponent {
public readonly hasPermissionToOpenDetails = input(true);
public readonly hasPermissionToShowQuantities = input(true);
public readonly hasPermissionToShowValues = input(true);
public readonly holdings = input.required<PortfolioPosition[]>();
public readonly holdings = input.required<PortfolioPosition[] | undefined>();
public readonly locale = input(getLocale());
public readonly pageSize = model(Number.MAX_SAFE_INTEGER);
@ -89,7 +89,7 @@ export class GfHoldingsTableComponent {
// Reactive data update
effect(() => {
this.dataSource.data = this.holdings();
this.dataSource.data = this.holdings() ?? [];
});
// Reactive view connection

120
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "3.65.0",
"version": "3.66.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "3.65.0",
"version": "3.66.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -47,9 +47,9 @@
"@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1",
"@rekog/mcp-nest": "2.0.0",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"@rekog/mcp-nest": "2.0.2",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"ai": "7.0.37",
@ -134,7 +134,7 @@
"@nx/storybook": "23.1.1",
"@nx/web": "23.1.1",
"@nx/workspace": "23.1.1",
"@prisma/config": "7.9.1",
"@prisma/config": "7.10.0",
"@schematics/angular": "22.1.4",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-themes": "10.5.7",
@ -165,7 +165,7 @@
"nx": "23.1.1",
"prettier": "3.9.6",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.9.1",
"prisma": "7.10.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "9.0.0",
@ -13186,24 +13186,24 @@
}
},
"node_modules/@prisma/adapter-pg": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.9.1.tgz",
"integrity": "sha512-Ho2RK1KanQxLNSC0sR5bpiiVep10sWPLXCcxK+KXfI/Q69TMRbiafSvLPv3V9snimX72rMCqGlyJ4sBO4lKTAw==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.10.0.tgz",
"integrity": "sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/driver-adapter-utils": "7.9.1",
"@prisma/driver-adapter-utils": "7.10.0",
"@types/pg": "^8.16.0",
"pg": "^8.16.3",
"postgres-array": "3.0.4"
}
},
"node_modules/@prisma/client": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.1.tgz",
"integrity": "sha512-+xgrh2EhJVF79wC0yX5G4PI1Rdcm7Qn/nekNQ+t/O153wtNggruHal+fXHSa0QE+Tp/Cw5wvxeCEhZZ59xGm8Q==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz",
"integrity": "sha512-Ubw/QS9JGIBSBUsyxAUQuK/Jcu0Tsva7le7QbLd91Kix9yJvYDdj5QkwgEbbZniH80dd+sziQcALPc+HnvQC8Q==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/client-runtime-utils": "7.9.1"
"@prisma/client-runtime-utils": "7.10.0"
},
"engines": {
"node": "^20.19 || ^22.12 || >=24.0"
@ -13222,15 +13222,15 @@
}
},
"node_modules/@prisma/client-runtime-utils": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.9.1.tgz",
"integrity": "sha512-mVIBGYdO5CFmK0HvjxrtfIyQQcPdb88pSCeVQriVQPVZyDovIWblpHfOgcS8QO187j3QF0ePArH8qPhp0AU2vg==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.10.0.tgz",
"integrity": "sha512-cnCy7lUV8/CctgKVEmqAbSLAmwqJdE/qAlqTBk/0NDk59zEb2cZ0M0M0E4vVPnqbSEYudRroQDvOWfUZH6RIfw==",
"license": "Apache-2.0"
},
"node_modules/@prisma/config": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.9.1.tgz",
"integrity": "sha512-4znKhxTmXmuPye9Z6pbIyYb5VZlkZ05qG1L6Dr4g+7oTwc6V50Bs9XirFBDdjWt+H/AabMn9aUnxBcvj8z05aA==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.10.0.tgz",
"integrity": "sha512-Rcg828gIRE3HOQ3pOATFjV5d/P0U9OIobxhd/IMxlfWjA4vru0eGwb0AIwFw0rmcLMVShohZYWPixVxkBHsxUA==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@ -13241,9 +13241,9 @@
}
},
"node_modules/@prisma/debug": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.1.tgz",
"integrity": "sha512-/cpVZ4itxtcgB8GHBvZtcmuEjq+lWsLrRJxFMbwZrT1RIdtuKmUm7PPGo/wzfbYpBrk+9WmmBE8CHJw2rybKDQ==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.10.0.tgz",
"integrity": "sha512-caygJKtltmRIgdJ3jRpkOr7yM4DW6zxo5uOmojKWFb3asnxWoRkQOwZmXBgD8FZp4htrX+nMpcWqDwzlQ1+Y4g==",
"license": "Apache-2.0"
},
"node_modules/@prisma/dev": {
@ -13271,65 +13271,65 @@
}
},
"node_modules/@prisma/driver-adapter-utils": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.9.1.tgz",
"integrity": "sha512-vmHehG7nn/heW32DXXpp13DxxAxVVe6n250oEt3dOL2E/4bt3olktKZN0mzSuxMMronyMSkbeW2uCOn3F4g8RQ==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.10.0.tgz",
"integrity": "sha512-u8zkcRLlaryO652T4qavBg0HmzNW5tSKdsCn6hc1PhWAp/J6k0vrxLuUs+b9o+HcjsK7Dfa01o4OFSn0frauJA==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.9.1"
"@prisma/debug": "7.10.0"
}
},
"node_modules/@prisma/engines": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.9.1.tgz",
"integrity": "sha512-UprXSMNXx2NF5ow4pqaQtE8OuBz6K78B0wc0tn2L28G5r933iWp1DR9Do2qWrsNvvFIP3x6mpEWnQtckMO0Uhg==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.10.0.tgz",
"integrity": "sha512-KNumN6NHFwybvfdYzTee9pqwx5PvknpWAaHn6L5NsbrKdl+SQrsVZs9opKs6U6SAsvB26HDt3WybRjOhgoWOYQ==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.9.1",
"@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad",
"@prisma/fetch-engine": "7.9.1",
"@prisma/get-platform": "7.9.1"
"@prisma/debug": "7.10.0",
"@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3",
"@prisma/fetch-engine": "7.10.0",
"@prisma/get-platform": "7.10.0"
}
},
"node_modules/@prisma/engines-version": {
"version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad.tgz",
"integrity": "sha512-2BsPPFksz3CQUXG6af3rVCtJKg6+JJGJTtfgu2fU8DdXhOfkBjulCq8mwybCd6ge0/jhZq2kOtLAbmUDMyI1nA==",
"version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3",
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3.tgz",
"integrity": "sha512-8OJ6RuZTZ06eFUOtBwxVmv8XMmOW6HWN5F+uxUbZkGxR0Bfab1dfAdXaHPmR5mb59E+fmUo8IOzXlbLY1SClbw==",
"devOptional": true,
"license": "Apache-2.0"
},
"node_modules/@prisma/engines/node_modules/@prisma/get-platform": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz",
"integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz",
"integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.9.1"
"@prisma/debug": "7.10.0"
}
},
"node_modules/@prisma/fetch-engine": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.9.1.tgz",
"integrity": "sha512-9DwxrNTeT25Orbu9CWh0CZvVlyY1lmscpbaeLZcOnuR7zcuFrt91YSmmOfIm7zJ08YOZ6mVzURKwLoMwEBcK8w==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.10.0.tgz",
"integrity": "sha512-Zqyu8DY14t6W/xwmAxUYWCXtHrvQnSvT644EAZSsdM8NSmCS74vJJbBKdVsK3ucFpnUWkEpbO1a0CxJXrg130g==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.9.1",
"@prisma/engines-version": "7.9.0-1.e922089b7d7502aff4249d5da3420f6fa55fc6ad",
"@prisma/get-platform": "7.9.1"
"@prisma/debug": "7.10.0",
"@prisma/engines-version": "7.10.0-4.0edf323efd1d98336f3f0a68684b56f689b900d3",
"@prisma/get-platform": "7.10.0"
}
},
"node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.9.1.tgz",
"integrity": "sha512-PK8R60YZRQvYxBrGG9i7l2/rFyzy+2MuI1dKtmtrCqPH8YpiJx/MfiC7LRzX5786rZDEv7BngcjfIJW4/9ADuw==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.10.0.tgz",
"integrity": "sha512-0bra1LFYi8xNw0yqV62bHJNQk4BKleOngZiqPWIQc7a3+9q6rqsnqJ15BuepP8943PFMvtmgnP52juZsyYkA6w==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.9.1"
"@prisma/debug": "7.10.0"
}
},
"node_modules/@prisma/get-platform": {
@ -13587,9 +13587,9 @@
}
},
"node_modules/@rekog/mcp-nest": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.0.tgz",
"integrity": "sha512-lT5V26V4fvPay50zMiw2KrEqlDSuzdAeyoQFd0+vfAwb40G6H8p8psTzgq5atTgNHktRPI7u4x8lrm8X844Asg==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@rekog/mcp-nest/-/mcp-nest-2.0.2.tgz",
"integrity": "sha512-kuye6wmRk5Gw4KZSCSXd83cWznRw4eFbvNUXsd8+1dYcS4uWKVVbRvxQIKNCyxc2UcljpsmLpnksr1NCRn3YAQ==",
"license": "MIT",
"dependencies": {
"multer": "^2.2.0",
@ -30547,16 +30547,16 @@
}
},
"node_modules/prisma": {
"version": "7.9.1",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.9.1.tgz",
"integrity": "sha512-aPqePoZIqwlAchbgbFDO/wHqGB+7H1nj9gaM+OsL9h77S5S3TnLd9BgD3LnoeDikULo7cl2HSUrEyQ55Z7DYbg==",
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/prisma/-/prisma-7.10.0.tgz",
"integrity": "sha512-o0ornyJOWgygVAzGCpr8PdXV8EJLHyVGDDUr/voBQt8Azzw8cYTByzzPGcA/m4tCkPcnJA8raEOv2CslsKhPEw==",
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@prisma/config": "7.9.1",
"@prisma/config": "7.10.0",
"@prisma/dev": "0.24.17",
"@prisma/engines": "7.9.1",
"@prisma/engines": "7.10.0",
"@prisma/studio-core": "0.33.0",
"mysql2": "3.15.3",
"postgres": "3.4.7"

12
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.65.0",
"version": "3.66.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -91,9 +91,9 @@
"@nestjs/serve-static": "5.0.5",
"@nestjs/throttler": "6.5.0",
"@openrouter/ai-sdk-provider": "3.0.0",
"@prisma/adapter-pg": "7.9.1",
"@prisma/client": "7.9.1",
"@rekog/mcp-nest": "2.0.0",
"@prisma/adapter-pg": "7.10.0",
"@prisma/client": "7.10.0",
"@rekog/mcp-nest": "2.0.2",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"ai": "7.0.37",
@ -178,7 +178,7 @@
"@nx/storybook": "23.1.1",
"@nx/web": "23.1.1",
"@nx/workspace": "23.1.1",
"@prisma/config": "7.9.1",
"@prisma/config": "7.10.0",
"@schematics/angular": "22.1.4",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-themes": "10.5.7",
@ -209,7 +209,7 @@
"nx": "23.1.1",
"prettier": "3.9.6",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "7.9.1",
"prisma": "7.10.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "9.0.0",

Loading…
Cancel
Save