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 58a3224c1..1d1deb7f8 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 @@ -1,5 +1,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; import { DataProviderGhostfolioStatusResponse, @@ -31,6 +33,7 @@ import { GhostfolioService } from './ghostfolio.service'; export class GhostfolioController { public constructor( private readonly ghostfolioService: GhostfolioService, + private readonly propertyService: PropertyService, @Inject(REQUEST) private readonly request: RequestWithUser ) {} @@ -150,6 +153,17 @@ export class GhostfolioController { @HasPermission(permissions.enableDataProviderGhostfolio) @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getStatus(): Promise { + const ghostfolioApiKey = (await this.propertyService.getByKey( + PROPERTY_API_KEY_GHOSTFOLIO + )) as string; + + if (!ghostfolioApiKey) { + throw new HttpException( + getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE), + StatusCodes.SERVICE_UNAVAILABLE + ); + } + return { dailyRequests: this.request.user.dataProviderGhostfolioDailyRequests, dailyRequestsMax: await this.ghostfolioService.getMaxDailyRequests() diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts index 62a78d1d8..904a97090 100644 --- a/apps/api/src/app/info/info.service.ts +++ b/apps/api/src/app/info/info.service.ts @@ -7,6 +7,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate- import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { DEFAULT_CURRENCY, + HEADER_KEY_TOKEN, PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_DEMO_USER_ID, @@ -347,7 +348,7 @@ export class InfoService { )}&to${format(new Date(), DATE_FORMAT)}`, { headers: { - Authorization: `Bearer ${this.configurationService.get( + [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( 'API_KEY_BETTER_UPTIME' )}` }, 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 d5c214d4e..a1ac6b657 100644 --- a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -12,7 +12,10 @@ import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; -import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config'; +import { + HEADER_KEY_TOKEN, + PROPERTY_API_KEY_GHOSTFOLIO +} from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataProviderInfo, @@ -212,7 +215,7 @@ export class GhostfolioService implements DataProviderInterface { private getRequestHeaders() { return { - Authorization: `Bearer ${this.apiKey}` + [HEADER_KEY_TOKEN]: `Bearer ${this.apiKey}` }; } } diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.html b/apps/client/src/app/components/admin-settings/admin-settings.component.html index b3a63df7a..2cd6072f1 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.html +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.html @@ -11,7 +11,9 @@ target="_blank" [href]="pricingUrl" > - NEW + @if (hasGhostfolioApiKey === false) { + NEW + } Ghostfolio Premium
- + @if (hasGhostfolioApiKey === true) { +
+
+ {{ ghostfolioApiStatus.dailyRequests }} + of + {{ ghostfolioApiStatus.dailyRequestsMax }} + daily requests +
+ +
+ } @else if (hasGhostfolioApiKey === false) { + + }
diff --git a/apps/client/src/app/components/admin-settings/admin-settings.component.ts b/apps/client/src/app/components/admin-settings/admin-settings.component.ts index 2dd2555bd..e2b9e1047 100644 --- a/apps/client/src/app/components/admin-settings/admin-settings.component.ts +++ b/apps/client/src/app/components/admin-settings/admin-settings.component.ts @@ -1,5 +1,13 @@ +import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type'; +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 { User } from '@ghostfolio/common/interfaces'; +import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config'; +import { + DataProviderGhostfolioStatusResponse, + User +} from '@ghostfolio/common/interfaces'; import { ChangeDetectionStrategy, @@ -10,7 +18,7 @@ import { } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { Subject, takeUntil } from 'rxjs'; +import { catchError, filter, of, Subject, takeUntil } from 'rxjs'; import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component'; @@ -21,6 +29,8 @@ import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api- templateUrl: './admin-settings.component.html' }) export class AdminSettingsComponent implements OnDestroy, OnInit { + public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse; + public hasGhostfolioApiKey: boolean; public pricingUrl: string; private deviceType: string; @@ -28,9 +38,12 @@ export class AdminSettingsComponent implements OnDestroy, OnInit { private user: User; public constructor( + private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, private deviceService: DeviceDetectorService, private matDialog: MatDialog, + private notificationService: NotificationService, private userService: UserService ) {} @@ -50,22 +63,72 @@ export class AdminSettingsComponent implements OnDestroy, OnInit { this.changeDetectorRef.markForCheck(); } }); + + this.initialize(); } - public onSetGhostfolioApiKey() { - this.matDialog.open(GfGhostfolioPremiumApiDialogComponent, { - autoFocus: false, - data: { - deviceType: this.deviceType, - pricingUrl: this.pricingUrl + public onRemoveGhostfolioApiKey() { + this.notificationService.confirm({ + confirmFn: () => { + this.dataService + .putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, { value: undefined }) + .subscribe(() => { + this.initialize(); + }); }, - height: this.deviceType === 'mobile' ? '98vh' : undefined, - width: this.deviceType === 'mobile' ? '100vw' : '50rem' + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to delete the API key?` }); } + public onSetGhostfolioApiKey() { + const dialogRef = this.matDialog.open( + GfGhostfolioPremiumApiDialogComponent, + { + autoFocus: false, + data: { + deviceType: this.deviceType, + pricingUrl: this.pricingUrl + }, + height: this.deviceType === 'mobile' ? '98vh' : undefined, + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + } + ); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.initialize(); + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete(); } + + private initialize() { + this.adminService + .fetchGhostfolioDataProviderStatus() + .pipe( + catchError(() => { + this.hasGhostfolioApiKey = false; + + this.changeDetectorRef.markForCheck(); + + return of(null); + }), + filter((status) => { + return status !== null; + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe((status) => { + this.ghostfolioApiStatus = status; + this.hasGhostfolioApiKey = true; + + this.changeDetectorRef.markForCheck(); + }); + } } diff --git a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts index 856ddc852..f15866f13 100644 --- a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts +++ b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts @@ -1,3 +1,5 @@ +import { DataService } from '@ghostfolio/client/services/data.service'; +import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { CommonModule } from '@angular/common'; @@ -30,10 +32,28 @@ import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces'; export class GfGhostfolioPremiumApiDialogComponent { public constructor( @Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams, + private dataService: DataService, public dialogRef: MatDialogRef ) {} public onCancel() { this.dialogRef.close(); } + + public onSetGhostfolioApiKey() { + let ghostfolioApiKey = prompt( + $localize`Please enter your Ghostfolio API key:` + ); + ghostfolioApiKey = ghostfolioApiKey?.trim(); + + if (ghostfolioApiKey) { + this.dataService + .putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, { + value: ghostfolioApiKey + }) + .subscribe(() => { + this.dialogRef.close(); + }); + } + } } diff --git a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html index 25673075d..f2f753750 100644 --- a/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html +++ b/apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html @@ -29,9 +29,19 @@ href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello%0D%0DPlease notify me as soon as the Ghostfolio Premium Data Provider is available.%0D%0DKind regards" i18n mat-flat-button + >Notify me - Notify me - +
+ or +
+ diff --git a/apps/client/src/app/core/auth.interceptor.ts b/apps/client/src/app/core/auth.interceptor.ts index b0dbdf641..7491cecf1 100644 --- a/apps/client/src/app/core/auth.interceptor.ts +++ b/apps/client/src/app/core/auth.interceptor.ts @@ -2,6 +2,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { HEADER_KEY_IMPERSONATION, + HEADER_KEY_SKIP_INTERCEPTOR, HEADER_KEY_TIMEZONE, HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; @@ -27,6 +28,16 @@ export class AuthInterceptor implements HttpInterceptor { next: HttpHandler ): Observable> { let request = req; + + if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) { + // Bypass the interceptor + request = request.clone({ + headers: req.headers.delete(HEADER_KEY_SKIP_INTERCEPTOR) + }); + + return next.handle(request); + } + let headers = request.headers.set( HEADER_KEY_TIMEZONE, Intl?.DateTimeFormat().resolvedOptions().timeZone diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 0e24533ef..203d3adf5 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -103,7 +103,7 @@ export class HttpResponseInterceptor implements HttpInterceptor { } else if (error.status === StatusCodes.UNAUTHORIZED) { if (this.webAuthnService.isEnabled()) { this.router.navigate(['/webauthn']); - } else { + } else if (!error.url.includes('/data-providers/ghostfolio/status')) { this.tokenStorageService.signOut(); } } diff --git a/apps/client/src/app/services/admin.service.ts b/apps/client/src/app/services/admin.service.ts index 20cfa8ef8..17f813fc9 100644 --- a/apps/client/src/app/services/admin.service.ts +++ b/apps/client/src/app/services/admin.service.ts @@ -5,6 +5,11 @@ import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform. import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto'; import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; +import { + HEADER_KEY_SKIP_INTERCEPTOR, + HEADER_KEY_TOKEN, + PROPERTY_API_KEY_GHOSTFOLIO +} from '@ghostfolio/common/config'; import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, @@ -14,7 +19,8 @@ import { AdminMarketDataDetails, AdminUsers, EnhancedSymbolProfile, - Filter + Filter, + DataProviderGhostfolioStatusResponse } from '@ghostfolio/common/interfaces'; import { HttpClient, HttpParams } from '@angular/common/http'; @@ -23,8 +29,9 @@ import { SortDirection } from '@angular/material/sort'; import { DataSource, MarketData, Platform, Tag } from '@prisma/client'; import { JobStatus } from 'bull'; import { format, parseISO } from 'date-fns'; -import { Observable, map } from 'rxjs'; +import { Observable, map, switchMap } from 'rxjs'; +import { environment } from '../../environments/environment'; import { DataService } from './data.service'; @Injectable({ @@ -136,6 +143,22 @@ export class AdminService { ); } + public fetchGhostfolioDataProviderStatus() { + return this.fetchAdminData().pipe( + switchMap(({ settings }) => { + 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]}` + } + } + ); + }) + ); + } + public fetchJobs({ status }: { status?: JobStatus[] }) { let params = new HttpParams(); diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 91c38b12c..7608e43a8 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -106,6 +106,7 @@ export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS: JobOptions = { export const HEADER_KEY_IMPERSONATION = 'Impersonation-Id'; export const HEADER_KEY_TIMEZONE = 'Timezone'; export const HEADER_KEY_TOKEN = 'Authorization'; +export const HEADER_KEY_SKIP_INTERCEPTOR = 'X-Skip-Interceptor'; export const MAX_TOP_HOLDINGS = 50;