From c85a1be3cf8153b69fad654c6f9cc6b736400762 Mon Sep 17 00:00:00 2001 From: Ayush2198-source Date: Wed, 4 Dec 2024 00:43:45 +0530 Subject: [PATCH 1/9] Feature/add pagination to users table (#4092) * Add pagination to users table * Update changelog --- CHANGELOG.md | 6 ++++ .../admin-users/admin-users.component.ts | 34 ++++++++++++++++--- .../components/admin-users/admin-users.html | 11 ++++++ .../admin-users/admin-users.module.ts | 2 ++ apps/client/src/app/services/admin.service.ts | 12 +++++-- 5 files changed, 59 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b287755de..aed13fe01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added pagination to the users table of the admin control panel + ## 2.125.0 - 2024-11-30 ### Changed diff --git a/apps/client/src/app/components/admin-users/admin-users.component.ts b/apps/client/src/app/components/admin-users/admin-users.component.ts index d619f4dd4..91c258f90 100644 --- a/apps/client/src/app/components/admin-users/admin-users.component.ts +++ b/apps/client/src/app/components/admin-users/admin-users.component.ts @@ -4,11 +4,19 @@ import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { getDateFormatString, getEmojiFlag } from '@ghostfolio/common/helper'; import { AdminUsers, InfoItem, User } from '@ghostfolio/common/interfaces'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; -import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { + ChangeDetectorRef, + Component, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatTableDataSource } from '@angular/material/table'; import { differenceInSeconds, @@ -24,6 +32,8 @@ import { takeUntil } from 'rxjs/operators'; templateUrl: './admin-users.html' }) export class AdminUsersComponent implements OnDestroy, OnInit { + @ViewChild(MatPaginator) paginator: MatPaginator; + public dataSource = new MatTableDataSource(); public defaultDateFormat: string; public displayedColumns: string[] = []; @@ -32,6 +42,8 @@ export class AdminUsersComponent implements OnDestroy, OnInit { public hasPermissionToImpersonateAllUsers: boolean; public info: InfoItem; public isLoading = false; + public pageSize = DEFAULT_PAGE_SIZE; + public totalItems = 0; public user: User; private unsubscribeSubject = new Subject(); @@ -137,19 +149,33 @@ export class AdminUsersComponent implements OnDestroy, OnInit { window.location.reload(); } + public onChangePage(page: PageEvent) { + this.fetchUsers({ + pageIndex: page.pageIndex + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } - private fetchUsers() { + private fetchUsers({ pageIndex }: { pageIndex: number } = { pageIndex: 0 }) { this.isLoading = true; + if (pageIndex === 0 && this.paginator) { + this.paginator.pageIndex = 0; + } + this.adminService - .fetchUsers() + .fetchUsers({ + skip: pageIndex * this.pageSize, + take: this.pageSize + }) .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe(({ users }) => { + .subscribe(({ count, users }) => { this.dataSource = new MatTableDataSource(users); + this.totalItems = count; this.isLoading = false; diff --git a/apps/client/src/app/components/admin-users/admin-users.html b/apps/client/src/app/components/admin-users/admin-users.html index 170b500ff..ca8ef0558 100644 --- a/apps/client/src/app/components/admin-users/admin-users.html +++ b/apps/client/src/app/components/admin-users/admin-users.html @@ -267,6 +267,17 @@ > + + + @if (isLoading) { ('/api/v1/tag'); } - public fetchUsers() { + public fetchUsers({ + skip, + take = DEFAULT_PAGE_SIZE + }: { + skip?: number; + take?: number; + }) { let params = new HttpParams(); - params = params.append('take', 30); + params = params.append('skip', skip); + params = params.append('take', take); return this.http.get('/api/v1/admin/user', { params }); } From 11d5f36c31debdc6138b5ae50e26f7390846c025 Mon Sep 17 00:00:00 2001 From: Amandee Ellawala <47607256+amandee27@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:50:22 +0000 Subject: [PATCH 2/9] Feature/extract historical market data editor to reusable component (#4080) * Extract historical market data editor to reusable component * Update changelog --- CHANGELOG.md | 4 + .../admin-market-data-detail.module.ts | 15 -- .../market-data-detail-dialog.module.ts | 26 ---- .../asset-profile-dialog.component.scss | 4 + .../asset-profile-dialog.component.ts | 76 ++++------ .../asset-profile-dialog.html | 48 ++---- .../asset-profile-dialog.module.ts | 6 +- ...cal-market-data-editor-dialog.component.ts | 42 ++++-- .../historical-market-data-editor-dialog.html | 0 .../historical-market-data-editor-dialog.scss | 0 .../interfaces/interfaces.ts | 2 +- ...storical-market-data-editor.component.html | 48 ++++-- ...storical-market-data-editor.component.scss | 4 - ...historical-market-data-editor.component.ts | 138 ++++++++++++++---- .../historical-market-data-editor/index.ts | 1 + 15 files changed, 234 insertions(+), 180 deletions(-) delete mode 100644 apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts delete mode 100644 apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.module.ts rename apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.component.ts => libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts (60%) rename apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html => libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html (100%) rename apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.scss => libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.scss (100%) rename {apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog => libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog}/interfaces/interfaces.ts (79%) rename apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html => libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html (51%) rename apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss => libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.scss (90%) rename apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts => libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts (55%) create mode 100644 libs/ui/src/lib/historical-market-data-editor/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aed13fe01..8e7a08306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added pagination to the users table of the admin control panel +### Changed + +- Extracted the historical market data editor to a reusable component + ## 2.125.0 - 2024-11-30 ### Changed diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts b/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts deleted file mode 100644 index 9f4e1b3bc..000000000 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { GfLineChartComponent } from '@ghostfolio/ui/line-chart'; - -import { CommonModule } from '@angular/common'; -import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; - -import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component'; -import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module'; - -@NgModule({ - declarations: [AdminMarketDataDetailComponent], - exports: [AdminMarketDataDetailComponent], - imports: [CommonModule, GfLineChartComponent, GfMarketDataDetailDialogModule], - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class GfAdminMarketDataDetailModule {} diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.module.ts b/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.module.ts deleted file mode 100644 index f3b55d71d..000000000 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.module.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { CommonModule } from '@angular/common'; -import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; -import { MatDatepickerModule } from '@angular/material/datepicker'; -import { MatDialogModule } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatInputModule } from '@angular/material/input'; - -import { MarketDataDetailDialog } from './market-data-detail-dialog.component'; - -@NgModule({ - declarations: [MarketDataDetailDialog], - imports: [ - CommonModule, - FormsModule, - MatButtonModule, - MatDatepickerModule, - MatDialogModule, - MatFormFieldModule, - MatInputModule, - ReactiveFormsModule - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] -}) -export class GfMarketDataDetailDialogModule {} diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss index b63df0134..7057aad83 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.scss @@ -3,5 +3,9 @@ .mat-mdc-dialog-content { max-height: unset; + + gf-line-chart { + aspect-ratio: 16/9; + } } } diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts index aacf387e7..4fdc22986 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts @@ -1,15 +1,17 @@ import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; -import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DataService } from '@ghostfolio/client/services/data.service'; +import { UserService } from '@ghostfolio/client/services/user/user.service'; import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { AdminMarketDataDetails, - AssetProfileIdentifier + AssetProfileIdentifier, + LineChartItem, + User } from '@ghostfolio/common/interfaces'; import { translate } from '@ghostfolio/ui/i18n'; @@ -23,7 +25,6 @@ import { } from '@angular/core'; import { FormBuilder, FormControl, Validators } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { AssetClass, AssetSubClass, @@ -31,7 +32,6 @@ import { SymbolProfile } from '@prisma/client'; import { format } from 'date-fns'; -import { parse as csvToJson } from 'papaparse'; import { EMPTY, Subject } from 'rxjs'; import { catchError, takeUntil } from 'rxjs/operators'; @@ -75,11 +75,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit { }; public currencies: string[] = []; public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix; + public historicalDataItems: LineChartItem[]; public isBenchmark = false; - public marketDataDetails: MarketData[] = []; + public marketDataItems: MarketData[] = []; public sectors: { [name: string]: { name: string; value: number }; }; + public user: User; private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( new Date(), @@ -96,7 +98,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit { public dialogRef: MatDialogRef, private formBuilder: FormBuilder, private notificationService: NotificationService, - private snackBar: MatSnackBar + private userService: UserService ) {} public ngOnInit() { @@ -109,6 +111,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit { } public initialize() { + this.historicalDataItems = undefined; + + this.userService.stateChanged + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((state) => { + if (state?.user) { + this.user = state.user; + } + }); + this.adminService .fetchAdminMarketDataBySymbol({ dataSource: this.data.dataSource, @@ -121,10 +133,19 @@ export class AssetProfileDialog implements OnDestroy, OnInit { this.assetProfileClass = translate(this.assetProfile?.assetClass); this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass); this.countries = {}; + this.isBenchmark = this.benchmarks.some(({ id }) => { return id === this.assetProfile.id; }); - this.marketDataDetails = marketData; + + this.historicalDataItems = marketData.map(({ date, marketPrice }) => { + return { + date: format(date, DATE_FORMAT), + value: marketPrice + }; + }); + + this.marketDataItems = marketData; this.sectors = {}; if (this.assetProfile?.countries?.length > 0) { @@ -200,47 +221,6 @@ export class AssetProfileDialog implements OnDestroy, OnInit { .subscribe(); } - public onImportHistoricalData() { - try { - const marketData = csvToJson( - this.assetProfileForm.controls['historicalData'].controls['csvString'] - .value, - { - dynamicTyping: true, - header: true, - skipEmptyLines: true - } - ).data as UpdateMarketDataDto[]; - - this.adminService - .postMarketData({ - dataSource: this.data.dataSource, - marketData: { - marketData - }, - symbol: this.data.symbol - }) - .pipe( - catchError(({ error, message }) => { - this.snackBar.open(`${error}: ${message[0]}`, undefined, { - duration: 3000 - }); - return EMPTY; - }), - takeUntil(this.unsubscribeSubject) - ) - .subscribe(() => { - this.initialize(); - }); - } catch { - this.snackBar.open( - $localize`Oops! Could not parse historical data.`, - undefined, - { duration: 3000 } - ); - } - } - public onMarketDataChanged(withRefresh: boolean = false) { if (withRefresh) { this.initialize(); diff --git a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html index a5d2205d2..eeb43e932 100644 --- a/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html +++ b/apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html @@ -68,50 +68,28 @@
- + -
- - - Historical Data (CSV) - - - -
- -
- -
-
(); public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams, + @Inject(MAT_DIALOG_DATA) + public data: HistoricalMarketDataEditorDialogParams, private dateAdapter: DateAdapter, - public dialogRef: MatDialogRef, + public dialogRef: MatDialogRef, @Inject(MAT_DATE_LOCALE) private locale: string ) {} diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html similarity index 100% rename from apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.html rename to libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.scss b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.scss similarity index 100% rename from apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/market-data-detail-dialog.scss rename to libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.scss diff --git a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts similarity index 79% rename from apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts rename to libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts index 81188cd1f..4248b3fdb 100644 --- a/apps/client/src/app/components/admin-market-data-detail/market-data-detail-dialog/interfaces/interfaces.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts @@ -2,7 +2,7 @@ import { User } from '@ghostfolio/common/interfaces'; import { DataSource } from '@prisma/client'; -export interface MarketDataDetailDialogParams { +export interface HistoricalMarketDataEditorDialogParams { currency: string; dataSource: DataSource; dateString: string; diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html similarity index 51% rename from apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html rename to libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html index 617dd6962..b35e1d812 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html @@ -1,14 +1,4 @@
- @for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) {
{{ itemByMonth.key }}
@@ -43,4 +33,42 @@
} +
+
+ + + Historical Data (CSV) + + + +
+ +
+ +
+
diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.scss similarity index 90% rename from apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss rename to libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.scss index a03533589..cc835a90e 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.scss @@ -2,10 +2,6 @@ display: block; font-size: 0.9rem; - gf-line-chart { - aspect-ratio: 16/9; - } - .date { font-feature-settings: 'tnum'; font-variant-numeric: tabular-nums; diff --git a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts similarity index 55% rename from apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts rename to libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts index 1742d8307..0fce78621 100644 --- a/apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts @@ -1,4 +1,5 @@ -import { UserService } from '@ghostfolio/client/services/user/user.service'; +import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; +import { AdminService } from '@ghostfolio/client/services/admin.service'; import { DATE_FORMAT, getDateFormatString, @@ -6,15 +7,22 @@ import { } from '@ghostfolio/common/helper'; import { LineChartItem, User } from '@ghostfolio/common/interfaces'; +import { CommonModule } from '@angular/common'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, + OnDestroy, + OnInit, Output } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { DataSource, MarketData } from '@prisma/client'; import { addDays, @@ -29,55 +37,70 @@ import { parseISO } from 'date-fns'; import { first, last } from 'lodash'; +import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Subject, takeUntil } from 'rxjs'; +import { parse as csvToJson } from 'papaparse'; +import { EMPTY, Subject, takeUntil } from 'rxjs'; +import { catchError } from 'rxjs/operators'; -import { MarketDataDetailDialogParams } from './market-data-detail-dialog/interfaces/interfaces'; -import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component'; +import { GfHistoricalMarketDataEditorDialogComponent } from './historical-market-data-editor-dialog/historical-market-data-editor-dialog.component'; +import { HistoricalMarketDataEditorDialogParams } from './historical-market-data-editor-dialog/interfaces/interfaces'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - selector: 'gf-admin-market-data-detail', - styleUrls: ['./admin-market-data-detail.component.scss'], - templateUrl: './admin-market-data-detail.component.html' + imports: [CommonModule, MatButtonModule, MatInputModule, ReactiveFormsModule], + selector: 'gf-historical-market-data-editor', + standalone: true, + styleUrls: ['./historical-market-data-editor.component.scss'], + templateUrl: './historical-market-data-editor.component.html' }) -export class AdminMarketDataDetailComponent implements OnChanges { +export class GfHistoricalMarketDataEditorComponent + implements OnChanges, OnDestroy, OnInit +{ @Input() currency: string; @Input() dataSource: DataSource; @Input() dateOfFirstActivity: string; @Input() locale = getLocale(); @Input() marketData: MarketData[]; @Input() symbol: string; + @Input() user: User; @Output() marketDataChanged = new EventEmitter(); public days = Array(31); public defaultDateFormat: string; public deviceType: string; + public historicalDataForm = this.formBuilder.group({ + historicalData: this.formBuilder.group({ + csvString: '' + }) + }); public historicalDataItems: LineChartItem[]; public marketDataByMonth: { [yearMonth: string]: { [day: string]: Pick & { day: number }; }; } = {}; - public user: User; + + private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( + new Date(), + DATE_FORMAT + )};123.45`; private unsubscribeSubject = new Subject(); public constructor( + private adminService: AdminService, private deviceService: DeviceDetectorService, private dialog: MatDialog, - private userService: UserService + private formBuilder: FormBuilder, + private snackBar: MatSnackBar ) { this.deviceType = this.deviceService.getDeviceInfo().deviceType; + } - this.userService.stateChanged - .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((state) => { - if (state?.user) { - this.user = state.user; - } - }); + public ngOnInit() { + this.initializeHistoricalDataForm(); } public ngOnChanges() { @@ -177,29 +200,84 @@ export class AdminMarketDataDetailComponent implements OnChanges { }) { const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; - const dialogRef = this.dialog.open(MarketDataDetailDialog, { - data: { - marketPrice, - currency: this.currency, - dataSource: this.dataSource, - dateString: `${yearMonth}-${day}`, - symbol: this.symbol, - user: this.user - } as MarketDataDetailDialogParams, - height: this.deviceType === 'mobile' ? '98vh' : '80vh', - width: this.deviceType === 'mobile' ? '100vw' : '50rem' - }); + const dialogRef = this.dialog.open( + GfHistoricalMarketDataEditorDialogComponent, + { + data: { + marketPrice, + currency: this.currency, + dataSource: this.dataSource, + dateString: `${yearMonth}-${day}`, + symbol: this.symbol, + user: this.user + } as HistoricalMarketDataEditorDialogParams, + height: this.deviceType === 'mobile' ? '98vh' : '80vh', + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + } + ); dialogRef .afterClosed() .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ withRefresh } = { withRefresh: false }) => { - this.marketDataChanged.next(withRefresh); + this.marketDataChanged.emit(withRefresh); }); } + public onImportHistoricalData() { + try { + const marketData = csvToJson( + this.historicalDataForm.controls['historicalData'].controls['csvString'] + .value, + { + dynamicTyping: true, + header: true, + skipEmptyLines: true + } + ).data as UpdateMarketDataDto[]; + + this.adminService + .postMarketData({ + dataSource: this.dataSource, + marketData: { + marketData + }, + symbol: this.symbol + }) + .pipe( + catchError(({ error, message }) => { + this.snackBar.open(`${error}: ${message[0]}`, undefined, { + duration: ms('3 seconds') + }); + return EMPTY; + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe(() => { + this.initializeHistoricalDataForm(); + + this.marketDataChanged.emit(true); + }); + } catch { + this.snackBar.open( + $localize`Oops! Could not parse historical data.`, + undefined, + { duration: ms('3 seconds') } + ); + } + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } + + private initializeHistoricalDataForm() { + this.historicalDataForm.setValue({ + historicalData: { + csvString: + GfHistoricalMarketDataEditorComponent.HISTORICAL_DATA_TEMPLATE + } + }); + } } diff --git a/libs/ui/src/lib/historical-market-data-editor/index.ts b/libs/ui/src/lib/historical-market-data-editor/index.ts new file mode 100644 index 000000000..6c7004ce9 --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/index.ts @@ -0,0 +1 @@ +export * from './historical-market-data-editor.component'; From 73be7f3969295b9d3ff7d147921c570f37fdab77 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:01:46 +0100 Subject: [PATCH 3/9] Feature/improve labels of assistant (#4091) * Improve labels * Update changelog --- CHANGELOG.md | 1 + libs/ui/src/lib/assistant/assistant.html | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7a08306..1ba5bf3a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Improved the labels of the assistant - Extracted the historical market data editor to a reusable component ## 2.125.0 - 2024-11-30 diff --git a/libs/ui/src/lib/assistant/assistant.html b/libs/ui/src/lib/assistant/assistant.html index 18c2145a3..228c9cc56 100644 --- a/libs/ui/src/lib/assistant/assistant.html +++ b/libs/ui/src/lib/assistant/assistant.html @@ -104,7 +104,7 @@
- Accounts + Account @for (account of accounts; track account.id) { @@ -152,7 +152,7 @@
- Tags + Tag @for (tag of tags; track tag.id) { @@ -163,7 +163,7 @@
- Asset Classes + Asset Class @for (assetClass of assetClasses; track assetClass.id) { From 45095cfac0c4d563ad2dd913f7fa9d445264588f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 7 Dec 2024 10:12:52 +0100 Subject: [PATCH 4/9] Feature/expire cache entries immediately in case of errors in portfolio snapshot calculation (#4099) * Expire cache entries immediately in case of errors * Update changelog --- CHANGELOG.md | 1 + .../queues/portfolio-snapshot/portfolio-snapshot.processor.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ba5bf3a1..fcefd5644 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Improved the labels of the assistant +- Improved the caching of the portfolio snapshot in the portfolio calculator by expiring cache entries immediately in case of errors - Extracted the historical market data editor to a reusable component ## 2.125.0 - 2024-11-30 diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts index a5a9a37e0..72e2a7ce3 100644 --- a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -86,7 +86,9 @@ export class PortfolioSnapshotProcessor { const expiration = addMilliseconds( new Date(), - this.configurationService.get('CACHE_QUOTES_TTL') + snapshot.errors.length === 0 + ? this.configurationService.get('CACHE_QUOTES_TTL') + : 0 ); this.redisCacheService.set( From 13582afd93d1677ca658ad4ac9bb4632ca884f22 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 7 Dec 2024 15:13:12 +0100 Subject: [PATCH 5/9] Feature/Setup API keys for Ghostfolio data provider (#4093) * Setup API keys for Ghostfolio data provider --- apps/api/src/app/app.module.ts | 2 + apps/api/src/app/auth/api-key.strategy.ts | 76 ++++++++ apps/api/src/app/auth/auth.module.ts | 4 + .../endpoints/api-keys/api-keys.controller.ts | 25 +++ .../app/endpoints/api-keys/api-keys.module.ts | 11 ++ .../ghostfolio/ghostfolio.controller.ts | 181 +++++++++++++++++- .../ghostfolio/ghostfolio.service.ts | 3 +- apps/api/src/app/user/user.service.ts | 25 +-- apps/api/src/helper/string.helper.ts | 14 ++ .../src/services/api-key/api-key.module.ts | 12 ++ .../src/services/api-key/api-key.service.ts | 63 ++++++ .../ghostfolio/ghostfolio.service.ts | 50 +++-- .../user-account-membership.component.ts | 56 +++++- .../user-account-membership.html | 2 + .../src/app/pages/api/api-page.component.ts | 43 ++++- apps/client/src/app/services/admin.service.ts | 16 +- apps/client/src/app/services/data.service.ts | 23 ++- libs/common/src/lib/interfaces/index.ts | 2 + .../responses/api-key-response.interface.ts | 3 + libs/common/src/lib/permissions.ts | 1 + .../membership-card.component.html | 19 ++ .../membership-card.component.scss | 6 + .../membership-card.component.ts | 17 +- package-lock.json | 11 ++ package.json | 1 + 25 files changed, 592 insertions(+), 74 deletions(-) create mode 100644 apps/api/src/app/auth/api-key.strategy.ts create mode 100644 apps/api/src/app/endpoints/api-keys/api-keys.controller.ts create mode 100644 apps/api/src/app/endpoints/api-keys/api-keys.module.ts create mode 100644 apps/api/src/helper/string.helper.ts create mode 100644 apps/api/src/services/api-key/api-key.module.ts create mode 100644 apps/api/src/services/api-key/api-key.service.ts create mode 100644 libs/common/src/lib/interfaces/responses/api-key-response.interface.ts diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 4fbdafb08..b1a240235 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthModule } from './auth/auth.module'; import { BenchmarkModule } from './benchmark/benchmark.module'; import { CacheModule } from './cache/cache.module'; +import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { PublicModule } from './endpoints/public/public.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; @@ -55,6 +56,7 @@ import { UserModule } from './user/user.module'; AdminModule, AccessModule, AccountModule, + ApiKeysModule, AssetModule, AuthDeviceModule, AuthModule, diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts new file mode 100644 index 000000000..ace7fb245 --- /dev/null +++ b/apps/api/src/app/auth/api-key.strategy.ts @@ -0,0 +1,76 @@ +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; +import { hasRole } from '@ghostfolio/common/permissions'; + +import { HttpException, Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; + +@Injectable() +export class ApiKeyStrategy extends PassportStrategy( + HeaderAPIKeyStrategy, + 'api-key' +) { + public constructor( + private readonly apiKeyService: ApiKeyService, + private readonly configurationService: ConfigurationService, + private readonly prismaService: PrismaService, + private readonly userService: UserService + ) { + super( + { header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, + true, + async (apiKey: string, done: (error: any, user?: any) => void) => { + try { + const user = await this.validateApiKey(apiKey); + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (hasRole(user, 'INACTIVE')) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + await this.prismaService.analytics.upsert({ + create: { User: { connect: { id: user.id } } }, + update: { + activityCount: { increment: 1 }, + lastRequestAt: new Date() + }, + where: { userId: user.id } + }); + } + + done(null, user); + } catch (error) { + done(error, null); + } + } + ); + } + + private async validateApiKey(apiKey: string) { + if (!apiKey) { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + + try { + const { id } = await this.apiKeyService.getUserByApiKey(apiKey); + + return this.userService.user({ id }); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + } +} diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts index 67b078c9b..824c432b1 100644 --- a/apps/api/src/app/auth/auth.module.ts +++ b/apps/api/src/app/auth/auth.module.ts @@ -2,6 +2,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; @@ -9,6 +10,7 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; +import { ApiKeyStrategy } from './api-key.strategy'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { GoogleStrategy } from './google.strategy'; @@ -28,6 +30,8 @@ import { JwtStrategy } from './jwt.strategy'; UserModule ], providers: [ + ApiKeyService, + ApiKeyStrategy, AuthDeviceService, AuthService, GoogleStrategy, diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts new file mode 100644 index 000000000..cbc68df93 --- /dev/null +++ b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts @@ -0,0 +1,25 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; + +@Controller('api-keys') +export class ApiKeysController { + public constructor( + private readonly apiKeyService: ApiKeyService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @HasPermission(permissions.createApiKey) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async createApiKey(): Promise { + return this.apiKeyService.create({ userId: this.request.user.id }); + } +} diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.module.ts b/apps/api/src/app/endpoints/api-keys/api-keys.module.ts new file mode 100644 index 000000000..123f11854 --- /dev/null +++ b/apps/api/src/app/endpoints/api-keys/api-keys.module.ts @@ -0,0 +1,11 @@ +import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module'; + +import { Module } from '@nestjs/common'; + +import { ApiKeysController } from './api-keys.controller'; + +@Module({ + controllers: [ApiKeysController], + imports: [ApiKeyModule] +}) +export class ApiKeysModule {} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts index 788cfd1bc..f3386f8a7 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts @@ -18,7 +18,8 @@ import { Inject, Param, Query, - UseGuards + UseGuards, + Version } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; @@ -36,9 +37,52 @@ export class GhostfolioController { @Inject(REQUEST) private readonly request: RequestWithUser ) {} + /** + * @deprecated + */ @Get('dividends/:symbol') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getDividendsV1( + @Param('symbol') symbol: string, + @Query() query: GetDividendsDto + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const dividends = await this.ghostfolioService.getDividends({ + symbol, + from: parseDate(query.from), + granularity: query.granularity, + to: parseDate(query.to) + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return dividends; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('dividends/:symbol') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') public async getDividends( @Param('symbol') symbol: string, @Query() query: GetDividendsDto @@ -75,9 +119,52 @@ export class GhostfolioController { } } + /** + * @deprecated + */ @Get('historical/:symbol') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getHistoricalV1( + @Param('symbol') symbol: string, + @Query() query: GetHistoricalDto + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const historicalData = await this.ghostfolioService.getHistorical({ + symbol, + from: parseDate(query.from), + granularity: query.granularity, + to: parseDate(query.to) + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return historicalData; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('historical/:symbol') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') public async getHistorical( @Param('symbol') symbol: string, @Query() query: GetHistoricalDto @@ -114,9 +201,51 @@ export class GhostfolioController { } } + /** + * @deprecated + */ @Get('lookup') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async lookupSymbolV1( + @Query('includeIndices') includeIndicesParam = 'false', + @Query('query') query = '' + ): Promise { + const includeIndices = includeIndicesParam === 'true'; + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const result = await this.ghostfolioService.lookup({ + includeIndices, + query: query.toLowerCase() + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return result; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('lookup') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') public async lookupSymbol( @Query('includeIndices') includeIndicesParam = 'false', @Query('query') query = '' @@ -152,9 +281,48 @@ export class GhostfolioController { } } + /** + * @deprecated + */ @Get('quotes') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getQuotesV1( + @Query() query: GetQuotesDto + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const quotes = await this.ghostfolioService.getQuotes({ + symbols: query.symbols + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return quotes; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('quotes') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') public async getQuotes( @Query() query: GetQuotesDto ): Promise { @@ -187,9 +355,20 @@ export class GhostfolioController { } } + /** + * @deprecated + */ @Get('status') @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getStatusV1(): Promise { + return this.ghostfolioService.getStatus({ user: this.request.user }); + } + + @Get('status') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') public async getStatus(): Promise { return this.ghostfolioService.getStatus({ user: this.request.user }); } diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts index 7858e24f0..78685a61b 100644 --- a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts @@ -220,8 +220,7 @@ export class GhostfolioService { public async incrementDailyRequests({ userId }: { userId: string }) { await this.prismaService.analytics.update({ data: { - dataProviderGhostfolioDailyRequests: { increment: 1 }, - lastRequestAt: new Date() + dataProviderGhostfolioDailyRequests: { increment: 1 } }, where: { userId } }); diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 54dafda22..6676a00b6 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -2,6 +2,7 @@ import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { environment } from '@ghostfolio/api/environments/environment'; import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { getRandomString } from '@ghostfolio/api/helper/string.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; @@ -37,11 +38,10 @@ import { UserWithSettings } from '@ghostfolio/common/types'; import { Injectable } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Prisma, Role, User } from '@prisma/client'; +import { createHmac } from 'crypto'; import { differenceInDays, subDays } from 'date-fns'; import { sortBy, without } from 'lodash'; -const crypto = require('crypto'); - @Injectable() export class UserService { private i18nService = new I18nService(); @@ -61,7 +61,7 @@ export class UserService { } public createAccessToken(password: string, salt: string): string { - const hash = crypto.createHmac('sha512', salt); + const hash = createHmac('sha512', salt); hash.update(password); return hash.digest('hex'); @@ -309,6 +309,7 @@ export class UserService { // Reset holdings view mode user.Settings.settings.holdingsViewMode = undefined; } else if (user.subscription?.type === 'Premium') { + currentPermissions.push(permissions.createApiKey); currentPermissions.push(permissions.enableDataProviderGhostfolio); currentPermissions.push(permissions.reportDataGlitch); @@ -408,10 +409,7 @@ export class UserService { } if (data.provider === 'ANONYMOUS') { - const accessToken = this.createAccessToken( - user.id, - this.getRandomString(10) - ); + const accessToken = this.createAccessToken(user.id, getRandomString(10)); const hashedAccessToken = this.createAccessToken( accessToken, @@ -528,17 +526,4 @@ export class UserService { return settings; } - - private getRandomString(length: number) { - const bytes = crypto.randomBytes(length); - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - const result = []; - - for (let i = 0; i < length; i++) { - const randomByte = bytes[i]; - result.push(characters[randomByte % characters.length]); - } - - return result.join(''); - } } diff --git a/apps/api/src/helper/string.helper.ts b/apps/api/src/helper/string.helper.ts new file mode 100644 index 000000000..38bac79f1 --- /dev/null +++ b/apps/api/src/helper/string.helper.ts @@ -0,0 +1,14 @@ +import { randomBytes } from 'crypto'; + +export function getRandomString(length: number) { + const bytes = randomBytes(length); + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const result = []; + + for (let i = 0; i < length; i++) { + const randomByte = bytes[i]; + result.push(characters[randomByte % characters.length]); + } + + return result.join(''); +} diff --git a/apps/api/src/services/api-key/api-key.module.ts b/apps/api/src/services/api-key/api-key.module.ts new file mode 100644 index 000000000..8681e3ad7 --- /dev/null +++ b/apps/api/src/services/api-key/api-key.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { ApiKeyService } from './api-key.service'; + +@Module({ + exports: [ApiKeyService], + imports: [PrismaModule], + providers: [ApiKeyService] +}) +export class ApiKeyModule {} diff --git a/apps/api/src/services/api-key/api-key.service.ts b/apps/api/src/services/api-key/api-key.service.ts new file mode 100644 index 000000000..2a1f14d03 --- /dev/null +++ b/apps/api/src/services/api-key/api-key.service.ts @@ -0,0 +1,63 @@ +import { getRandomString } from '@ghostfolio/api/helper/string.helper'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; +import { pbkdf2Sync } from 'crypto'; + +@Injectable() +export class ApiKeyService { + private readonly algorithm = 'sha256'; + private readonly iterations = 100000; + private readonly keyLength = 64; + + public constructor(private readonly prismaService: PrismaService) {} + + public async create({ userId }: { userId: string }): Promise { + const apiKey = this.generateApiKey(); + const hashedKey = this.hashApiKey(apiKey); + + await this.prismaService.apiKey.deleteMany({ where: { userId } }); + + await this.prismaService.apiKey.create({ + data: { + hashedKey, + userId + } + }); + + return { apiKey }; + } + + public async getUserByApiKey(apiKey: string) { + const hashedKey = this.hashApiKey(apiKey); + + const { user } = await this.prismaService.apiKey.findFirst({ + include: { user: true }, + where: { hashedKey } + }); + + return user; + } + + public hashApiKey(apiKey: string): string { + return pbkdf2Sync( + apiKey, + '', + this.iterations, + this.keyLength, + this.algorithm + ).toString('hex'); + } + + private generateApiKey(): string { + return getRandomString(32) + .split('') + .reduce((acc, char, index) => { + const chunkIndex = Math.floor(index / 4); + acc[chunkIndex] = (acc[chunkIndex] || '') + char; + return acc; + }, []) + .join('-'); + } +} diff --git a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts index 25ffdc677..7102176ae 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -93,7 +93,7 @@ export class GhostfolioService implements DataProviderInterface { }, requestTimeout); const { dividends } = await got( - `${this.URL}/v1/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( + `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( to, DATE_FORMAT )}`, @@ -111,8 +111,13 @@ export class GhostfolioService implements DataProviderInterface { if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { message = 'RequestError: The daily request limit has been exceeded'; } else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { - message = - 'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + if (!error.request?.options?.headers?.authorization?.includes('-')) { + message = + 'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } else { + message = + 'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; + } } Logger.error(message, 'GhostfolioService'); @@ -138,7 +143,7 @@ export class GhostfolioService implements DataProviderInterface { }, requestTimeout); const { historicalData } = await got( - `${this.URL}/v1/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( + `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format( to, DATE_FORMAT )}`, @@ -158,8 +163,13 @@ export class GhostfolioService implements DataProviderInterface { if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { message = 'RequestError: The daily request limit has been exceeded'; } else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { - message = - 'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + if (!error.request?.options?.headers?.authorization?.includes('-')) { + message = + 'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } else { + message = + 'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; + } } Logger.error(message, 'GhostfolioService'); @@ -201,7 +211,7 @@ export class GhostfolioService implements DataProviderInterface { }, requestTimeout); const { quotes } = await got( - `${this.URL}/v1/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`, + `${this.URL}/v2/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`, { headers: await this.getRequestHeaders(), // @ts-ignore @@ -213,15 +223,20 @@ export class GhostfolioService implements DataProviderInterface { } catch (error) { let message = error; - if (error?.code === 'ABORT_ERR') { + if (error.code === 'ABORT_ERR') { message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( this.configurationService.get('REQUEST_TIMEOUT') / 1000 ).toFixed(3)} seconds`; } else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { message = 'RequestError: The daily request limit has been exceeded'; } else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { - message = - 'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + if (!error.request?.options?.headers?.authorization?.includes('-')) { + message = + 'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } else { + message = + 'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; + } } Logger.error(message, 'GhostfolioService'); @@ -245,7 +260,7 @@ export class GhostfolioService implements DataProviderInterface { }, this.configurationService.get('REQUEST_TIMEOUT')); searchResult = await got( - `${this.URL}/v1/data-providers/ghostfolio/lookup?query=${query}`, + `${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`, { headers: await this.getRequestHeaders(), // @ts-ignore @@ -255,15 +270,20 @@ export class GhostfolioService implements DataProviderInterface { } catch (error) { let message = error; - if (error?.code === 'ABORT_ERR') { + if (error.code === 'ABORT_ERR') { message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( this.configurationService.get('REQUEST_TIMEOUT') / 1000 ).toFixed(3)} seconds`; } else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { message = 'RequestError: The daily request limit has been exceeded'; } else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) { - message = - 'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + if (!error.request?.options?.headers?.authorization?.includes('-')) { + message = + 'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } else { + message = + 'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.'; + } } Logger.error(message, 'GhostfolioService'); @@ -278,7 +298,7 @@ export class GhostfolioService implements DataProviderInterface { )) as string; return { - [HEADER_KEY_TOKEN]: `Bearer ${apiKey}` + [HEADER_KEY_TOKEN]: `Api-Key ${apiKey}` }; } } diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts index bde555d8e..9b9242dfa 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.component.ts @@ -1,3 +1,4 @@ +import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; import { NotificationService } from '@ghostfolio/client/core/notification/notification.service'; import { DataService } from '@ghostfolio/client/services/data.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; @@ -16,7 +17,7 @@ import { MatSnackBarRef, TextOnlySnackBar } from '@angular/material/snack-bar'; -import { StringValue } from 'ms'; +import ms, { StringValue } from 'ms'; import { StripeService } from 'ngx-stripe'; import { EMPTY, Subject } from 'rxjs'; import { catchError, switchMap, takeUntil } from 'rxjs/operators'; @@ -34,6 +35,7 @@ export class UserAccountMembershipComponent implements OnDestroy { public defaultDateFormat: string; public durationExtension: StringValue; public hasPermissionForSubscription: boolean; + public hasPermissionToCreateApiKey: boolean; public hasPermissionToUpdateUserSettings: boolean; public price: number; public priceId: string; @@ -73,6 +75,11 @@ export class UserAccountMembershipComponent implements OnDestroy { this.user.settings.locale ); + this.hasPermissionToCreateApiKey = hasPermission( + this.user.permissions, + permissions.createApiKey + ); + this.hasPermissionToUpdateUserSettings = hasPermission( this.user.permissions, permissions.updateUserSettings @@ -100,15 +107,15 @@ export class UserAccountMembershipComponent implements OnDestroy { this.dataService .createCheckoutSession({ couponId: this.couponId, priceId: this.priceId }) .pipe( - switchMap(({ sessionId }: { sessionId: string }) => { - return this.stripeService.redirectToCheckout({ sessionId }); - }), catchError((error) => { this.notificationService.alert({ title: error.message }); throw error; + }), + switchMap(({ sessionId }: { sessionId: string }) => { + return this.stripeService.redirectToCheckout({ sessionId }); }) ) .subscribe((result) => { @@ -120,6 +127,41 @@ export class UserAccountMembershipComponent implements OnDestroy { }); } + public onGenerateApiKey() { + this.notificationService.confirm({ + confirmFn: () => { + this.dataService + .postApiKey() + .pipe( + catchError(() => { + this.snackBar.open( + '😞 ' + $localize`Could not generate an API key`, + undefined, + { + duration: ms('3 seconds') + } + ); + + return EMPTY; + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe(({ apiKey }) => { + this.notificationService.alert({ + discardLabel: $localize`Okay`, + message: + $localize`Set this API key in your self-hosted environment:` + + '
' + + apiKey, + title: $localize`Ghostfolio Premium Data Provider API Key` + }); + }); + }, + confirmType: ConfirmationDialogType.Primary, + title: $localize`Do you really want to generate a new API key?` + }); + } + public onRedeemCoupon() { let couponCode = prompt($localize`Please enter your coupon code:`); couponCode = couponCode?.trim(); @@ -128,18 +170,18 @@ export class UserAccountMembershipComponent implements OnDestroy { this.dataService .redeemCoupon(couponCode) .pipe( - takeUntil(this.unsubscribeSubject), catchError(() => { this.snackBar.open( '😞 ' + $localize`Could not redeem coupon code`, undefined, { - duration: 3000 + duration: ms('3 seconds') } ); return EMPTY; - }) + }), + takeUntil(this.unsubscribeSubject) ) .subscribe(() => { this.snackBarRef = this.snackBar.open( diff --git a/apps/client/src/app/components/user-account-membership/user-account-membership.html b/apps/client/src/app/components/user-account-membership/user-account-membership.html index 82b329a64..64dd2ce8f 100644 --- a/apps/client/src/app/components/user-account-membership/user-account-membership.html +++ b/apps/client/src/app/components/user-account-membership/user-account-membership.html @@ -4,7 +4,9 @@
@if (user?.subscription?.type === 'Basic') {
diff --git a/apps/client/src/app/pages/api/api-page.component.ts b/apps/client/src/app/pages/api/api-page.component.ts index aa176c0f0..a45efd9b4 100644 --- a/apps/client/src/app/pages/api/api-page.component.ts +++ b/apps/client/src/app/pages/api/api-page.component.ts @@ -1,3 +1,7 @@ +import { + HEADER_KEY_SKIP_INTERCEPTOR, + HEADER_KEY_TOKEN +} from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataProviderGhostfolioStatusResponse, @@ -8,7 +12,7 @@ import { } from '@ghostfolio/common/interfaces'; import { CommonModule } from '@angular/common'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Component, OnInit } from '@angular/core'; import { format, startOfYear } from 'date-fns'; import { map, Observable, Subject, takeUntil } from 'rxjs'; @@ -28,11 +32,14 @@ export class GfApiPageComponent implements OnInit { public status$: Observable; public symbols$: Observable; + private apiKey: string; private unsubscribeSubject = new Subject(); public constructor(private http: HttpClient) {} public ngOnInit() { + this.apiKey = prompt($localize`Please enter your Ghostfolio API key:`); + this.dividends$ = this.fetchDividends({ symbol: 'KO' }); this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' }); this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] }); @@ -52,8 +59,11 @@ export class GfApiPageComponent implements OnInit { return this.http .get( - `/api/v1/data-providers/ghostfolio/dividends/${symbol}`, - { params } + `/api/v2/data-providers/ghostfolio/dividends/${symbol}`, + { + params, + headers: this.getHeaders() + } ) .pipe( map(({ dividends }) => { @@ -70,8 +80,11 @@ export class GfApiPageComponent implements OnInit { return this.http .get( - `/api/v1/data-providers/ghostfolio/historical/${symbol}`, - { params } + `/api/v2/data-providers/ghostfolio/historical/${symbol}`, + { + params, + headers: this.getHeaders() + } ) .pipe( map(({ historicalData }) => { @@ -85,8 +98,9 @@ export class GfApiPageComponent implements OnInit { const params = new HttpParams().set('symbols', symbols.join(',')); return this.http - .get('/api/v1/data-providers/ghostfolio/quotes', { - params + .get('/api/v2/data-providers/ghostfolio/quotes', { + params, + headers: this.getHeaders() }) .pipe( map(({ quotes }) => { @@ -99,7 +113,8 @@ export class GfApiPageComponent implements OnInit { private fetchStatus() { return this.http .get( - '/api/v1/data-providers/ghostfolio/status' + '/api/v2/data-providers/ghostfolio/status', + { headers: this.getHeaders() } ) .pipe(takeUntil(this.unsubscribeSubject)); } @@ -118,8 +133,9 @@ export class GfApiPageComponent implements OnInit { } return this.http - .get('/api/v1/data-providers/ghostfolio/lookup', { - params + .get('/api/v2/data-providers/ghostfolio/lookup', { + params, + headers: this.getHeaders() }) .pipe( map(({ items }) => { @@ -128,4 +144,11 @@ export class GfApiPageComponent implements OnInit { takeUntil(this.unsubscribeSubject) ); } + + private getHeaders() { + return new HttpHeaders({ + [HEADER_KEY_SKIP_INTERCEPTOR]: 'true', + [HEADER_KEY_TOKEN]: `Api-Key ${this.apiKey}` + }); + } } diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index 5d252f00f..77d135f57 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/apps/client/src/app/services/admin.service.ts @@ -24,7 +24,7 @@ import { Filter } from '@ghostfolio/common/interfaces'; -import { HttpClient, HttpParams } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { SortDirection } from '@angular/material/sort'; import { DataSource, MarketData, Platform, Tag } from '@prisma/client'; @@ -147,14 +147,14 @@ export class AdminService { public fetchGhostfolioDataProviderStatus() { return this.fetchAdminData().pipe( switchMap(({ settings }) => { + const headers = new HttpHeaders({ + [HEADER_KEY_SKIP_INTERCEPTOR]: 'true', + [HEADER_KEY_TOKEN]: `Api-Key ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}` + }); + return this.http.get( - `${environment.production ? 'https://ghostfol.io' : ''}/api/v1/data-providers/ghostfolio/status`, - { - headers: { - [HEADER_KEY_SKIP_INTERCEPTOR]: 'true', - [HEADER_KEY_TOKEN]: `Bearer ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}` - } - } + `${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`, + { headers } ); }) ); diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index dccbb064a..92d030827 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -22,6 +22,7 @@ import { AccountBalancesResponse, Accounts, AdminMarketDataDetails, + ApiKeyResponse, AssetProfileIdentifier, BenchmarkMarketDataDetails, BenchmarkResponse, @@ -289,7 +290,7 @@ export class DataService { public deleteActivities({ filters }) { const params = this.buildFiltersAsQueryParams({ filters }); - return this.http.delete(`/api/v1/order`, { params }); + return this.http.delete('/api/v1/order', { params }); } public deleteActivity(aId: string) { @@ -636,36 +637,40 @@ export class DataService { } public loginAnonymous(accessToken: string) { - return this.http.post(`/api/v1/auth/anonymous`, { + return this.http.post('/api/v1/auth/anonymous', { accessToken }); } public postAccess(aAccess: CreateAccessDto) { - return this.http.post(`/api/v1/access`, aAccess); + return this.http.post('/api/v1/access', aAccess); } public postAccount(aAccount: CreateAccountDto) { - return this.http.post(`/api/v1/account`, aAccount); + return this.http.post('/api/v1/account', aAccount); } public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) { return this.http.post( - `/api/v1/account-balance`, + '/api/v1/account-balance', aAccountBalance ); } + public postApiKey() { + return this.http.post('/api/v1/api-keys', {}); + } + public postBenchmark(benchmark: AssetProfileIdentifier) { - return this.http.post(`/api/v1/benchmark`, benchmark); + return this.http.post('/api/v1/benchmark', benchmark); } public postOrder(aOrder: CreateOrderDto) { - return this.http.post(`/api/v1/order`, aOrder); + return this.http.post('/api/v1/order', aOrder); } public postUser() { - return this.http.post(`/api/v1/user`, {}); + return this.http.post('/api/v1/user', {}); } public putAccount(aAccount: UpdateAccountDto) { @@ -692,7 +697,7 @@ export class DataService { } public putUserSetting(aData: UpdateUserSettingDto) { - return this.http.put(`/api/v1/user/setting`, aData); + return this.http.put('/api/v1/user/setting', aData); } public redeemCoupon(couponCode: string) { diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 4d5ce66d0..344a1f965 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -39,6 +39,7 @@ import type { PortfolioSummary } from './portfolio-summary.interface'; import type { Position } from './position.interface'; import type { Product } from './product'; import type { AccountBalancesResponse } from './responses/account-balances-response.interface'; +import type { ApiKeyResponse } from './responses/api-key-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface'; import type { DividendsResponse } from './responses/dividends-response.interface'; @@ -72,6 +73,7 @@ export { AdminMarketDataDetails, AdminMarketDataItem, AdminUsers, + ApiKeyResponse, AssetProfileIdentifier, Benchmark, BenchmarkMarketDataDetails, diff --git a/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts b/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts new file mode 100644 index 000000000..dace14a02 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts @@ -0,0 +1,3 @@ +export interface ApiKeyResponse { + apiKey: string; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts index 1a81938b5..cfee1c9e8 100644 --- a/libs/common/src/lib/permissions.ts +++ b/libs/common/src/lib/permissions.ts @@ -9,6 +9,7 @@ export const permissions = { createAccess: 'createAccess', createAccount: 'createAccount', createAccountBalance: 'createAccountBalance', + createApiKey: 'createApiKey', createOrder: 'createOrder', createPlatform: 'createPlatform', createTag: 'createTag', diff --git a/libs/ui/src/lib/membership-card/membership-card.component.html b/libs/ui/src/lib/membership-card/membership-card.component.html index 02a4a03f7..37634b020 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.html +++ b/libs/ui/src/lib/membership-card/membership-card.component.html @@ -13,6 +13,25 @@ [showLabel]="false" />
+ @if (hasPermissionToCreateApiKey) { +
+
API Key
+
+
* * * * * * * * *
+
+ +
+
+
+ }
Membership
diff --git a/libs/ui/src/lib/membership-card/membership-card.component.scss b/libs/ui/src/lib/membership-card/membership-card.component.scss index a7cbce91a..270adc0f1 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.scss +++ b/libs/ui/src/lib/membership-card/membership-card.component.scss @@ -42,6 +42,12 @@ background-color: #1d2124; border-radius: calc(var(--borderRadius) - var(--borderWidth)); color: rgba(var(--light-primary-text)); + line-height: 1.2; + + button { + color: rgba(var(--light-primary-text)); + height: 1.5rem; + } .heading { font-size: 13px; diff --git a/libs/ui/src/lib/membership-card/membership-card.component.ts b/libs/ui/src/lib/membership-card/membership-card.component.ts index b19072946..5d05d6fe5 100644 --- a/libs/ui/src/lib/membership-card/membership-card.component.ts +++ b/libs/ui/src/lib/membership-card/membership-card.component.ts @@ -3,15 +3,18 @@ import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, Component, - Input + EventEmitter, + Input, + Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; import { RouterModule } from '@angular/router'; import { GfLogoComponent } from '../logo'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [CommonModule, GfLogoComponent, RouterModule], + imports: [CommonModule, GfLogoComponent, MatButtonModule, RouterModule], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-membership-card', standalone: true, @@ -20,7 +23,17 @@ import { GfLogoComponent } from '../logo'; }) export class GfMembershipCardComponent { @Input() public expiresAt: string; + @Input() public hasPermissionToCreateApiKey: boolean; @Input() public name: string; + @Output() generateApiKeyClicked = new EventEmitter(); + public routerLinkPricing = ['/' + $localize`:snake-case:pricing`]; + + public onGenerateApiKey(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.generateApiKeyClicked.emit(); + } } diff --git a/package-lock.json b/package-lock.json index ae7b60a44..0b93a63b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -83,6 +83,7 @@ "papaparse": "5.3.1", "passport": "0.7.0", "passport-google-oauth20": "2.0.0", + "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", "reflect-metadata": "0.1.13", "rxjs": "7.5.6", @@ -28414,6 +28415,16 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-headerapikey": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/passport-headerapikey/-/passport-headerapikey-1.2.2.tgz", + "integrity": "sha512-4BvVJRrWsNJPrd3UoZfcnnl4zvUWYKEtfYkoDsaOKBsrWHYmzTApCjs7qUbncOLexE9ul0IRiYBFfBG0y9IVQA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15", + "passport-strategy": "^1.0.0" + } + }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", diff --git a/package.json b/package.json index 7f124ea20..24e0d64a4 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,7 @@ "papaparse": "5.3.1", "passport": "0.7.0", "passport-google-oauth20": "2.0.0", + "passport-headerapikey": "1.2.2", "passport-jwt": "4.0.1", "reflect-metadata": "0.1.13", "rxjs": "7.5.6", From 1be0a6441787c9b95e8571b76add7aa3c1976db1 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 7 Dec 2024 15:32:47 +0100 Subject: [PATCH 6/9] Feature/upgrade prettier to version 3.4.2 (#4090) * Upgrade prettier to version 3.4.2 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcefd5644..d4043459f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the labels of the assistant - Improved the caching of the portfolio snapshot in the portfolio calculator by expiring cache entries immediately in case of errors - Extracted the historical market data editor to a reusable component +- Upgraded `prettier` from version `3.3.3` to `3.4.2` ## 2.125.0 - 2024-11-30 diff --git a/package-lock.json b/package-lock.json index 0b93a63b8..be8e0a412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -149,7 +149,7 @@ "jest-environment-jsdom": "29.7.0", "jest-preset-angular": "14.1.0", "nx": "20.1.2", - "prettier": "3.3.3", + "prettier": "3.4.2", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "6.0.0", "react": "18.2.0", @@ -29419,9 +29419,9 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 24e0d64a4..668422621 100644 --- a/package.json +++ b/package.json @@ -195,7 +195,7 @@ "jest-environment-jsdom": "29.7.0", "jest-preset-angular": "14.1.0", "nx": "20.1.2", - "prettier": "3.3.3", + "prettier": "3.4.2", "prettier-plugin-organize-attributes": "1.0.0", "prisma": "6.0.0", "react": "18.2.0", From 0e016745527860ecc26a16fc551da489996f9f5b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 7 Dec 2024 15:44:53 +0100 Subject: [PATCH 7/9] Feature/set hashedKey of ApiKey to unique (#4103) * Set hashedKey to unique --- apps/api/src/services/api-key/api-key.service.ts | 2 +- .../migration.sql | 5 +++++ prisma/schema.prisma | 3 +-- 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20241207142023_set_hashed_key_of_api_key_to_unique/migration.sql diff --git a/apps/api/src/services/api-key/api-key.service.ts b/apps/api/src/services/api-key/api-key.service.ts index 2a1f14d03..f70e5330c 100644 --- a/apps/api/src/services/api-key/api-key.service.ts +++ b/apps/api/src/services/api-key/api-key.service.ts @@ -32,7 +32,7 @@ export class ApiKeyService { public async getUserByApiKey(apiKey: string) { const hashedKey = this.hashApiKey(apiKey); - const { user } = await this.prismaService.apiKey.findFirst({ + const { user } = await this.prismaService.apiKey.findUnique({ include: { user: true }, where: { hashedKey } }); diff --git a/prisma/migrations/20241207142023_set_hashed_key_of_api_key_to_unique/migration.sql b/prisma/migrations/20241207142023_set_hashed_key_of_api_key_to_unique/migration.sql new file mode 100644 index 000000000..f9a6eecbb --- /dev/null +++ b/prisma/migrations/20241207142023_set_hashed_key_of_api_key_to_unique/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "ApiKey_hashedKey_idx"; + +-- CreateIndex +CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e2587acf7..7df28d694 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,13 +79,12 @@ model Analytics { model ApiKey { createdAt DateTime @default(now()) - hashedKey String + hashedKey String @unique id String @id @default(uuid()) updatedAt DateTime @updatedAt userId String user User @relation(fields: [userId], onDelete: Cascade, references: [id]) - @@index([hashedKey]) @@index([userId]) } From 758a52087d7e58ac8c3809ad5d9aaf3f7205bbaf Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 7 Dec 2024 15:51:55 +0100 Subject: [PATCH 8/9] Feature/upgrade prisma to version 6.0.1 (#4104) * Upgrade prisma to version 6.0.1 * Update changelog --- CHANGELOG.md | 1 + package-lock.json | 54 +++++++++++++++++++++++------------------------ package.json | 4 ++-- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4043459f..42ab80c84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the caching of the portfolio snapshot in the portfolio calculator by expiring cache entries immediately in case of errors - Extracted the historical market data editor to a reusable component - Upgraded `prettier` from version `3.3.3` to `3.4.2` +- Upgraded `prisma` from version `6.0.0` to `6.0.1` ## 2.125.0 - 2024-11-30 diff --git a/package-lock.json b/package-lock.json index be8e0a412..212714bab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "@nestjs/platform-express": "10.1.3", "@nestjs/schedule": "3.0.2", "@nestjs/serve-static": "4.0.0", - "@prisma/client": "6.0.0", + "@prisma/client": "6.0.1", "@simplewebauthn/browser": "9.0.1", "@simplewebauthn/server": "9.0.3", "@stripe/stripe-js": "4.9.0", @@ -151,7 +151,7 @@ "nx": "20.1.2", "prettier": "3.4.2", "prettier-plugin-organize-attributes": "1.0.0", - "prisma": "6.0.0", + "prisma": "6.0.1", "react": "18.2.0", "react-dom": "18.2.0", "replace-in-file": "7.0.1", @@ -8353,9 +8353,9 @@ "license": "MIT" }, "node_modules/@prisma/client": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.0.0.tgz", - "integrity": "sha512-tOBhG35ozqZ/5Y6B0TNOa6cwULUW8ijXqBXcgb12bfozqf6eGMyGs+jphywCsj6uojv5lAZZnxVSoLMVebIP+g==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.0.1.tgz", + "integrity": "sha512-60w7kL6bUxz7M6Gs/V+OWMhwy94FshpngVmOY05TmGD0Lhk+Ac0ZgtjlL6Wll9TD4G03t4Sq1wZekNVy+Xdlbg==", "hasInstallScript": true, "license": "Apache-2.0", "engines": { @@ -8371,24 +8371,24 @@ } }, "node_modules/@prisma/debug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.0.tgz", - "integrity": "sha512-eUjoNThlDXdyJ1iQ2d7U6aTVwm59EwvODb5zFVNJEokNoSiQmiYWNzZIwZyDmZ+j51j42/0iTaHIJ4/aZPKFRg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.0.1.tgz", + "integrity": "sha512-jQylgSOf7ibTVxqBacnAlVGvek6fQxJIYCQOeX2KexsfypNzXjJQSS2o5s+Mjj2Np93iSOQUaw6TvPj8syhG4w==", "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.0.0.tgz", - "integrity": "sha512-ZZCVP3q22ifN6Ex6C8RIcTDBlRtMJS2H1ljV0knCiWNGArvvkEbE88W3uDdq/l4+UvyvHpGzdf9ZsCWSQR7ZQQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.0.1.tgz", + "integrity": "sha512-4hxzI+YQIR2uuDyVsDooFZGu5AtixbvM2psp+iayDZ4hRrAHo/YwgA17N23UWq7G6gRu18NvuNMb48qjP3DPQw==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.0.0", + "@prisma/debug": "6.0.1", "@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", - "@prisma/fetch-engine": "6.0.0", - "@prisma/get-platform": "6.0.0" + "@prisma/fetch-engine": "6.0.1", + "@prisma/get-platform": "6.0.1" } }, "node_modules/@prisma/engines-version": { @@ -8399,25 +8399,25 @@ "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.0.0.tgz", - "integrity": "sha512-j2m+iO5RDPRI7SUc7sHo8wX7SA4iTkJ+18Sxch8KinQM46YiCQD1iXKN6qU79C1Fliw5Bw/qDyTHaTsa3JMerA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.0.1.tgz", + "integrity": "sha512-T36bWFVGeGYYSyYOj9d+O9G3sBC+pAyMC+jc45iSL63/Haq1GrYjQPgPMxrEj9m739taXrupoysRedQ+VyvM/Q==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.0.0", + "@prisma/debug": "6.0.1", "@prisma/engines-version": "5.23.0-27.5dbef10bdbfb579e07d35cc85fb1518d357cb99e", - "@prisma/get-platform": "6.0.0" + "@prisma/get-platform": "6.0.1" } }, "node_modules/@prisma/get-platform": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.0.0.tgz", - "integrity": "sha512-PS6nYyIm9g8C03E4y7LknOfdCw/t2KyEJxntMPQHQZCOUgOpF82Ma60mdlOD08w90I3fjLiZZ0+MadenR3naDQ==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.0.1.tgz", + "integrity": "sha512-zspC9vlxAqx4E6epMPMLLBMED2VD8axDe8sPnquZ8GOsn6tiacWK0oxrGK4UAHYzYUVuMVUApJbdXB2dFpLhvg==", "devOptional": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "6.0.0" + "@prisma/debug": "6.0.1" } }, "node_modules/@redis/bloom": { @@ -29500,14 +29500,14 @@ } }, "node_modules/prisma": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.0.0.tgz", - "integrity": "sha512-RX7KtbW7IoEByf7MR32JK1PkVYLVYFqeODTtiIX3cqekq1aKdsF3Eud4zp2sUShMLjvdb5Jow0LbUjRq5LVxPw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.0.1.tgz", + "integrity": "sha512-CaMNFHkf+DDq8zq3X/JJsQ4Koy7dyWwwtOKibkT/Am9j/tDxcfbg7+lB1Dzhx18G/+RQCMgjPYB61bhRqteNBQ==", "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/engines": "6.0.0" + "@prisma/engines": "6.0.1" }, "bin": { "prisma": "build/index.js" diff --git a/package.json b/package.json index 668422621..10ade46de 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "@nestjs/platform-express": "10.1.3", "@nestjs/schedule": "3.0.2", "@nestjs/serve-static": "4.0.0", - "@prisma/client": "6.0.0", + "@prisma/client": "6.0.1", "@simplewebauthn/browser": "9.0.1", "@simplewebauthn/server": "9.0.3", "@stripe/stripe-js": "4.9.0", @@ -197,7 +197,7 @@ "nx": "20.1.2", "prettier": "3.4.2", "prettier-plugin-organize-attributes": "1.0.0", - "prisma": "6.0.0", + "prisma": "6.0.1", "react": "18.2.0", "react-dom": "18.2.0", "replace-in-file": "7.0.1", From 17ffb29275505fda5b670333dc4da0646674a3ff Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 7 Dec 2024 15:54:29 +0100 Subject: [PATCH 9/9] Release 2.126.0 (#4105) --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ab80c84..b162d220e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## 2.126.0 - 2024-12-07 ### Added diff --git a/package-lock.json b/package-lock.json index 212714bab..4957619e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostfolio", - "version": "2.125.0", + "version": "2.126.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostfolio", - "version": "2.125.0", + "version": "2.126.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/package.json b/package.json index 10ade46de..f86c028b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostfolio", - "version": "2.125.0", + "version": "2.126.0", "homepage": "https://ghostfol.io", "license": "AGPL-3.0", "repository": "https://github.com/ghostfolio/ghostfolio",