Browse Source

Extend Admin Control panel

pull/4016/head
Thomas Kaul 9 months ago
parent
commit
c8193dbce7
  1. 14
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  2. 3
      apps/api/src/app/info/info.service.ts
  3. 7
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  4. 38
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  5. 83
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  6. 20
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts
  7. 14
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html
  8. 11
      apps/client/src/app/core/auth.interceptor.ts
  9. 2
      apps/client/src/app/core/http-response.interceptor.ts
  10. 27
      apps/client/src/app/services/admin.service.ts
  11. 1
      libs/common/src/lib/config.ts

14
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; 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 { parseDate } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
@ -31,6 +33,7 @@ import { GhostfolioService } from './ghostfolio.service';
export class GhostfolioController { export class GhostfolioController {
public constructor( public constructor(
private readonly ghostfolioService: GhostfolioService, private readonly ghostfolioService: GhostfolioService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -150,6 +153,17 @@ export class GhostfolioController {
@HasPermission(permissions.enableDataProviderGhostfolio) @HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> { public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
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 { return {
dailyRequests: this.request.user.dataProviderGhostfolioDailyRequests, dailyRequests: this.request.user.dataProviderGhostfolioDailyRequests,
dailyRequestsMax: await this.ghostfolioService.getMaxDailyRequests() dailyRequestsMax: await this.ghostfolioService.getMaxDailyRequests()

3
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID, PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS, PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID, PROPERTY_DEMO_USER_ID,
@ -347,7 +348,7 @@ export class InfoService {
)}&to${format(new Date(), DATE_FORMAT)}`, )}&to${format(new Date(), DATE_FORMAT)}`,
{ {
headers: { headers: {
Authorization: `Bearer ${this.configurationService.get( [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME' 'API_KEY_BETTER_UPTIME'
)}` )}`
}, },

7
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -12,7 +12,10 @@ import {
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; 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 { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
@ -212,7 +215,7 @@ export class GhostfolioService implements DataProviderInterface {
private getRequestHeaders() { private getRequestHeaders() {
return { return {
Authorization: `Bearer ${this.apiKey}` [HEADER_KEY_TOKEN]: `Bearer ${this.apiKey}`
}; };
} }
} }

38
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -11,7 +11,9 @@
target="_blank" target="_blank"
[href]="pricingUrl" [href]="pricingUrl"
> >
<span class="badge badge-warning mr-1" i18n>NEW</span> @if (hasGhostfolioApiKey === false) {
<span class="badge badge-warning mr-1" i18n>NEW</span>
}
Ghostfolio Premium Ghostfolio Premium
<gf-premium-indicator <gf-premium-indicator
class="d-inline-block ml-1" class="d-inline-block ml-1"
@ -20,14 +22,32 @@
</a> </a>
</div> </div>
<div class="w-50"> <div class="w-50">
<button @if (hasGhostfolioApiKey === true) {
color="accent" <div class="align-items-center d-flex flex-wrap">
mat-flat-button <div class="mr-3">
(click)="onSetGhostfolioApiKey()" {{ ghostfolioApiStatus.dailyRequests }}
> <ng-container i18n>of</ng-container>
<ion-icon class="mr-1" name="key-outline" /> {{ ghostfolioApiStatus.dailyRequestsMax }}
<span i18n>Set API Key</span> <ng-container i18n>daily requests</ng-container>
</button> </div>
<button
color="warn"
mat-flat-button
(click)="onRemoveGhostfolioApiKey()"
>
<span i18n>Remove API key</span>
</button>
</div>
} @else if (hasGhostfolioApiKey === false) {
<button
color="accent"
mat-flat-button
(click)="onSetGhostfolioApiKey()"
>
<ion-icon class="mr-1" name="key-outline" />
<span i18n>Set API key</span>
</button>
}
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>

83
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 { 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 { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
@ -10,7 +18,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { DeviceDetectorService } from 'ngx-device-detector'; 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'; 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' templateUrl: './admin-settings.component.html'
}) })
export class AdminSettingsComponent implements OnDestroy, OnInit { export class AdminSettingsComponent implements OnDestroy, OnInit {
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public hasGhostfolioApiKey: boolean;
public pricingUrl: string; public pricingUrl: string;
private deviceType: string; private deviceType: string;
@ -28,9 +38,12 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
private user: User; private user: User;
public constructor( public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private matDialog: MatDialog, private matDialog: MatDialog,
private notificationService: NotificationService,
private userService: UserService private userService: UserService
) {} ) {}
@ -50,22 +63,72 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
this.initialize();
} }
public onSetGhostfolioApiKey() { public onRemoveGhostfolioApiKey() {
this.matDialog.open(GfGhostfolioPremiumApiDialogComponent, { this.notificationService.confirm({
autoFocus: false, confirmFn: () => {
data: { this.dataService
deviceType: this.deviceType, .putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, { value: undefined })
pricingUrl: this.pricingUrl .subscribe(() => {
this.initialize();
});
}, },
height: this.deviceType === 'mobile' ? '98vh' : undefined, confirmType: ConfirmationDialogType.Warn,
width: this.deviceType === 'mobile' ? '100vw' : '50rem' 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() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); 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();
});
}
} }

20
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 { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -30,10 +32,28 @@ import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
export class GfGhostfolioPremiumApiDialogComponent { export class GfGhostfolioPremiumApiDialogComponent {
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams, @Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent> public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent>
) {} ) {}
public onCancel() { public onCancel() {
this.dialogRef.close(); 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();
});
}
}
} }

14
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" 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 i18n
mat-flat-button mat-flat-button
>Notify me</a
> >
Notify me <div>
</a> <small class="text-muted" i18n>or</small>
</div>
<button
color="accent"
i18n
mat-stroked-button
(click)="onSetGhostfolioApiKey()"
>
I have an API key
</button>
</div> </div>
</div> </div>

11
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 { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { import {
HEADER_KEY_IMPERSONATION, HEADER_KEY_IMPERSONATION,
HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TIMEZONE, HEADER_KEY_TIMEZONE,
HEADER_KEY_TOKEN HEADER_KEY_TOKEN
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -27,6 +28,16 @@ export class AuthInterceptor implements HttpInterceptor {
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<any>> {
let request = req; 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( let headers = request.headers.set(
HEADER_KEY_TIMEZONE, HEADER_KEY_TIMEZONE,
Intl?.DateTimeFormat().resolvedOptions().timeZone Intl?.DateTimeFormat().resolvedOptions().timeZone

2
apps/client/src/app/core/http-response.interceptor.ts

@ -103,7 +103,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/webauthn']); this.router.navigate(['/webauthn']);
} else { } else if (!error.url.includes('/data-providers/ghostfolio/status')) {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
} }
} }

27
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 { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto'; import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; 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 { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
@ -14,7 +19,8 @@ import {
AdminMarketDataDetails, AdminMarketDataDetails,
AdminUsers, AdminUsers,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter,
DataProviderGhostfolioStatusResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { HttpClient, HttpParams } from '@angular/common/http'; 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 { DataSource, MarketData, Platform, Tag } from '@prisma/client';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { format, parseISO } from 'date-fns'; 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'; import { DataService } from './data.service';
@Injectable({ @Injectable({
@ -136,6 +143,22 @@ export class AdminService {
); );
} }
public fetchGhostfolioDataProviderStatus() {
return this.fetchAdminData().pipe(
switchMap(({ settings }) => {
return this.http.get<DataProviderGhostfolioStatusResponse>(
`${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[] }) { public fetchJobs({ status }: { status?: JobStatus[] }) {
let params = new HttpParams(); let params = new HttpParams();

1
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_IMPERSONATION = 'Impersonation-Id';
export const HEADER_KEY_TIMEZONE = 'Timezone'; export const HEADER_KEY_TIMEZONE = 'Timezone';
export const HEADER_KEY_TOKEN = 'Authorization'; export const HEADER_KEY_TOKEN = 'Authorization';
export const HEADER_KEY_SKIP_INTERCEPTOR = 'X-Skip-Interceptor';
export const MAX_TOP_HOLDINGS = 50; export const MAX_TOP_HOLDINGS = 50;

Loading…
Cancel
Save