From 040981e77c7e6f7e4565fe15eddf0d3cacc85b2b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:52:19 +0200 Subject: [PATCH 01/12] Task/extract file drop area of import activities dialog to reusable component (#8244) * Extract file drop area * Update changelog --- CHANGELOG.md | 5 + .../file-drop/file-drop.directive.ts | 44 ------ .../import-activities-dialog.component.ts | 107 +++++++------- .../import-activities-dialog.html | 56 +------- .../import-activities-dialog.scss | 44 ------ .../file-drop-area.component.html | 36 +++++ .../file-drop-area.component.scss | 45 ++++++ .../file-drop-area.component.stories.ts | 55 ++++++++ .../file-drop-area.component.ts | 130 ++++++++++++++++++ libs/ui/src/lib/file-drop-area/index.ts | 2 + .../file-drop-area/interfaces/interfaces.ts | 5 + 11 files changed, 346 insertions(+), 183 deletions(-) delete mode 100644 apps/client/src/app/directives/file-drop/file-drop.directive.ts create mode 100644 libs/ui/src/lib/file-drop-area/file-drop-area.component.html create mode 100644 libs/ui/src/lib/file-drop-area/file-drop-area.component.scss create mode 100644 libs/ui/src/lib/file-drop-area/file-drop-area.component.stories.ts create mode 100644 libs/ui/src/lib/file-drop-area/file-drop-area.component.ts create mode 100644 libs/ui/src/lib/file-drop-area/index.ts create mode 100644 libs/ui/src/lib/file-drop-area/interfaces/interfaces.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e35621f0b..1e34aa4caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a _Storybook_ story for the file drop area component + ### Changed - Improved the usability of the activities page by eliminating the reload on canceling the import activities dialog - Improved the accessibility of the _Buy me a coffee_ button on the about page - Improved the accessibility of the entity logo component +- Extracted the file drop area of the import activities dialog to a reusable component - Improved the performance of the historical market data table of the admin control panel by loading only the latest market price per asset profile - Introduced a cooldown of 12 hours for the asset profile and exchange rate gathering on activity creation by retaining the completed jobs diff --git a/apps/client/src/app/directives/file-drop/file-drop.directive.ts b/apps/client/src/app/directives/file-drop/file-drop.directive.ts deleted file mode 100644 index ae772bb500..0000000000 --- a/apps/client/src/app/directives/file-drop/file-drop.directive.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Directive, output } from '@angular/core'; - -@Directive({ - host: { - '(document:paste)': 'onPaste($event)', - '(dragenter)': 'onDragEnter($event)', - '(dragover)': 'onDragOver($event)', - '(drop)': 'onDrop($event)' - }, - selector: '[gfFileDrop]' -}) -export class GfFileDropDirective { - public readonly filesDropped = output(); - - public onDragEnter(event: DragEvent) { - event.preventDefault(); - event.stopPropagation(); - } - - public onDragOver(event: DragEvent) { - event.preventDefault(); - event.stopPropagation(); - } - - public onDrop(event: DragEvent) { - event.preventDefault(); - event.stopPropagation(); - - if (event.dataTransfer) { - // Prevent the browser's default behavior for handling the file drop - event.dataTransfer.dropEffect = 'copy'; - this.filesDropped.emit(event.dataTransfer.files); - } - } - - public onPaste(event: ClipboardEvent) { - const files = event.clipboardData?.files; - - if (files?.length) { - event.preventDefault(); - this.filesDropped.emit(files); - } - } -} diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts index 6ebcc4f2ad..21136af668 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts @@ -1,4 +1,3 @@ -import { GfFileDropDirective } from '@ghostfolio/client/directives/file-drop/file-drop.directive'; import { ImportActivitiesService } from '@ghostfolio/client/services/import-activities.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; import { @@ -23,6 +22,10 @@ import { AssetClass } from '@ghostfolio/prisma/enums'; import { GfActivitiesTableComponent } from '@ghostfolio/ui/activities-table'; import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; +import { + FileFormat, + GfFileDropAreaComponent +} from '@ghostfolio/ui/file-drop-area'; import { DataService } from '@ghostfolio/ui/services'; import { @@ -67,11 +70,7 @@ import { MatTableDataSource } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; import { IonIcon } from '@ionic/angular/standalone'; import { addIcons } from 'ionicons'; -import { - cloudUploadOutline, - informationCircleOutline, - warningOutline -} from 'ionicons/icons'; +import { informationCircleOutline, warningOutline } from 'ionicons/icons'; import { isArray, sortBy } from 'lodash-es'; import ms from 'ms'; import { from, switchMap } from 'rxjs'; @@ -90,7 +89,7 @@ import { GfActivitiesTableComponent, GfDialogFooterComponent, GfDialogHeaderComponent, - GfFileDropDirective, + GfFileDropAreaComponent, IonIcon, MatButtonModule, MatCheckboxModule, @@ -108,14 +107,49 @@ import { templateUrl: 'import-activities-dialog.html' }) export class GfImportActivitiesDialogComponent { - private static readonly FILE_EXTENSIONS_OF_GHOSTFOLIO_AI = [ - 'csv', - 'jpeg', - 'jpg', - 'pdf', - 'png' + private static readonly FILE_FORMAT_CSV: FileFormat = { + extensions: ['csv'], + name: 'CSV', + sampleUrl: + 'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.csv' + }; + + private static readonly FILE_FORMAT_JSON: FileFormat = { + extensions: ['json'], + name: 'JSON', + sampleUrl: + 'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.json' + }; + + private static readonly FILE_FORMATS: FileFormat[] = [ + GfImportActivitiesDialogComponent.FILE_FORMAT_CSV, + GfImportActivitiesDialogComponent.FILE_FORMAT_JSON + ]; + + private static readonly FILE_FORMATS_OF_GHOSTFOLIO_AI: FileFormat[] = [ + GfImportActivitiesDialogComponent.FILE_FORMAT_CSV, + { extensions: ['jpeg', 'jpg'], name: 'JPG' }, + { extensions: ['pdf'], name: 'PDF' }, + { extensions: ['png'], name: 'PNG' } ]; + private static readonly FILE_EXTENSIONS_OF_GHOSTFOLIO_AI = + GfImportActivitiesDialogComponent.FILE_FORMATS_OF_GHOSTFOLIO_AI.flatMap( + ({ extensions }) => { + return extensions; + } + ); + + private static readonly FILE_FORMATS_WITH_GHOSTFOLIO_AI = sortBy( + [ + ...GfImportActivitiesDialogComponent.FILE_FORMATS_OF_GHOSTFOLIO_AI, + GfImportActivitiesDialogComponent.FILE_FORMAT_JSON + ], + ({ name }) => { + return name; + } + ); + protected readonly assetProfileForm = new FormGroup({ assetProfileIdentifier: new FormControl(null, { validators: [Validators.required] @@ -127,6 +161,7 @@ export class GfImportActivitiesDialogComponent { protected details: any[] = []; protected dialogTitle = $localize`Import Activities`; protected errorMessages: string[] = []; + protected readonly ghostfolioAiLoadingText = $localize`Ghostfolio AI is processing the file...`; protected readonly ghostfolioAiTooltip = $localize`The content of your file will be processed by third-party AI providers. Please remove any personal data before uploading.`; protected readonly hasPermissionToEnableGhostfolioAi = hasPermission( @@ -144,6 +179,7 @@ export class GfImportActivitiesDialogComponent { protected isLoading = false; protected isMaxActivitiesOfGhostfolioAiReached = false; + protected readonly language = document.documentElement.lang; protected readonly maxActivitiesOfGhostfolioAi = AI_MAX_ACTIVITIES; protected mode: 'DIVIDEND'; protected pageIndex = 0; @@ -178,7 +214,13 @@ export class GfImportActivitiesDialogComponent { private readonly userService = inject(UserService); public constructor() { - addIcons({ cloudUploadOutline, informationCircleOutline, warningOutline }); + addIcons({ informationCircleOutline, warningOutline }); + } + + protected get fileFormats() { + return this.isGhostfolioAiEnabled + ? GfImportActivitiesDialogComponent.FILE_FORMATS_WITH_GHOSTFOLIO_AI + : GfImportActivitiesDialogComponent.FILE_FORMATS; } public ngOnInit() { @@ -271,18 +313,18 @@ export class GfImportActivitiesDialogComponent { } } - protected onFilesDropped({ - files, + protected onFileSelected({ + file, stepper }: { - files: FileList; + file: File; stepper: MatStepper; }) { - if (files.length === 0 || this.importStep !== ImportStep.UPLOAD_FILE) { + if (this.importStep !== ImportStep.UPLOAD_FILE) { return; } - this.handleFile({ stepper, file: files[0] }); + this.handleFile({ file, stepper }); } protected onGhostfolioAiChange({ checked }: MatCheckboxChange) { @@ -348,33 +390,6 @@ export class GfImportActivitiesDialogComponent { aStepper.reset(); } - protected onSelectFile(stepper: MatStepper) { - const input = document.createElement('input'); - - input.accept = this.isGhostfolioAiEnabled - ? [ - 'application/JSON', - ...GfImportActivitiesDialogComponent.FILE_EXTENSIONS_OF_GHOSTFOLIO_AI.map( - (fileExtension) => { - return `.${fileExtension}`; - } - ) - ].join(', ') - : 'application/JSON, .csv'; - input.type = 'file'; - - input.onchange = (event) => { - // Getting the file reference - const file = (event.target as HTMLInputElement).files?.[0]; - - if (file) { - this.handleFile({ file, stepper }); - } - }; - - input.click(); - } - protected updateSelection(activities: Activity[]) { this.selectedActivities = activities.filter(({ error }) => { return !error; diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html index 75c3ea5458..eea156f9c2 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.html @@ -74,55 +74,13 @@ } @else {
- @if (isLoading) { -
- - Ghostfolio AI is processing the file... -
- } @else { - - } -

- - The following file formats are supported: - CSV - @if (isGhostfolioAiEnabled) { - , JPG, - } @else { - or - } - JSON - @if (isGhostfolioAiEnabled) { - , PDF - or - PNG - } - -

+ @if ( data.deviceType !== 'mobile' && hasPermissionToEnableGhostfolioAi ) { diff --git a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss index 1b46f06863..edf449bd69 100644 --- a/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss +++ b/apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.scss @@ -4,10 +4,6 @@ display: block; .mat-mdc-dialog-content { - a { - color: rgba(var(--palette-primary-500), 1); - } - mat-stepper { @include mat.stepper-overrides( ( @@ -25,35 +21,6 @@ } } - .drop-area { - background-color: rgba(var(--palette-foreground-base), 0.02); - border: 1px dashed - rgba( - var(--palette-foreground-divider), - var(--palette-foreground-divider-alpha) - ); - border-radius: 0.25rem; - - &:hover { - border-color: rgba(var(--palette-primary-500), 1) !important; - color: rgba(var(--palette-primary-500), 1); - } - - .cloud-icon { - font-size: 2.5rem; - } - - .mat-mdc-progress-spinner { - @include mat.progress-spinner-overrides( - ( - active-indicator-color: var(--gf-theme-primary-500) - ) - ); - - margin: 0.625rem 0; - } - } - .mat-mdc-form-field { .mat-mdc-progress-spinner { right: 1.5rem; @@ -80,14 +47,3 @@ } } } - -:host-context(.theme-dark) { - .mat-mdc-dialog-content { - .drop-area { - border-color: rgba( - var(--palette-foreground-divider-dark), - var(--palette-foreground-divider-dark-alpha) - ); - } - } -} diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.html b/libs/ui/src/lib/file-drop-area/file-drop-area.component.html new file mode 100644 index 0000000000..b5c6e19021 --- /dev/null +++ b/libs/ui/src/lib/file-drop-area/file-drop-area.component.html @@ -0,0 +1,36 @@ +@if (isLoading()) { +
+ + {{ loadingText() }} +
+} @else { + +} +

+ + The following file formats are supported: + @for (fileFormatListPart of fileFormatListParts(); track $index) { + @if (fileFormatListPart.sampleUrl) { + {{ + fileFormatListPart.value + }} + } @else { + {{ fileFormatListPart.value }} + } + } + +

diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.scss b/libs/ui/src/lib/file-drop-area/file-drop-area.component.scss new file mode 100644 index 0000000000..9702ccf4c2 --- /dev/null +++ b/libs/ui/src/lib/file-drop-area/file-drop-area.component.scss @@ -0,0 +1,45 @@ +@use '@angular/material' as mat; + +:host { + a { + color: rgba(var(--palette-primary-500), 1); + } + + .drop-area { + background-color: rgba(var(--palette-foreground-base), 0.02); + border: 1px dashed + rgba( + var(--palette-foreground-divider), + var(--palette-foreground-divider-alpha) + ); + border-radius: 0.25rem; + + &:hover { + border-color: rgba(var(--palette-primary-500), 1) !important; + color: rgba(var(--palette-primary-500), 1); + } + + .cloud-icon { + font-size: 2.5rem; + } + + .mat-mdc-progress-spinner { + @include mat.progress-spinner-overrides( + ( + active-indicator-color: var(--gf-theme-primary-500) + ) + ); + + margin: 0.625rem 0; + } + } +} + +:host-context(.theme-dark) { + .drop-area { + border-color: rgba( + var(--palette-foreground-divider-dark), + var(--palette-foreground-divider-dark-alpha) + ); + } +} diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.stories.ts b/libs/ui/src/lib/file-drop-area/file-drop-area.component.stories.ts new file mode 100644 index 0000000000..eeee334b07 --- /dev/null +++ b/libs/ui/src/lib/file-drop-area/file-drop-area.component.stories.ts @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { GfFileDropAreaComponent } from './file-drop-area.component'; +import { FileFormat } from './interfaces/interfaces'; + +const FILE_FORMAT_CSV: FileFormat = { + extensions: ['csv'], + name: 'CSV', + sampleUrl: + 'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.csv' +}; + +const FILE_FORMAT_JSON: FileFormat = { + extensions: ['json'], + name: 'JSON', + sampleUrl: + 'https://github.com/ghostfolio/ghostfolio/blob/main/test/import/ok/sample.json' +}; + +const FILE_FORMATS_WITH_GHOSTFOLIO_AI: FileFormat[] = [ + FILE_FORMAT_CSV, + { extensions: ['jpeg', 'jpg'], name: 'JPG' }, + FILE_FORMAT_JSON, + { extensions: ['pdf'], name: 'PDF' }, + { extensions: ['png'], name: 'PNG' } +]; + +export default { + args: { + loadingText: 'Processing the file...' + }, + component: GfFileDropAreaComponent, + title: 'File Drop Area' +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + fileFormats: [FILE_FORMAT_CSV, FILE_FORMAT_JSON] + } +}; + +export const Loading: Story = { + args: { + fileFormats: FILE_FORMATS_WITH_GHOSTFOLIO_AI, + isLoading: true + } +}; + +export const WithGhostfolioAi: Story = { + args: { + fileFormats: FILE_FORMATS_WITH_GHOSTFOLIO_AI + } +}; diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.ts b/libs/ui/src/lib/file-drop-area/file-drop-area.component.ts new file mode 100644 index 0000000000..f63730e533 --- /dev/null +++ b/libs/ui/src/lib/file-drop-area/file-drop-area.component.ts @@ -0,0 +1,130 @@ +import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; + +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output +} from '@angular/core'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { cloudUploadOutline } from 'ionicons/icons'; + +import { FileFormat } from './interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '(document:paste)': 'onPaste($event)', + class: 'd-flex flex-column' + }, + imports: [IonIcon, MatProgressSpinnerModule], + selector: 'gf-file-drop-area', + styleUrls: ['./file-drop-area.component.scss'], + templateUrl: './file-drop-area.component.html' +}) +export class GfFileDropAreaComponent { + public readonly fileFormats = input.required(); + public readonly isLoading = input(false); + public readonly language = input(DEFAULT_LANGUAGE_CODE); + public readonly loadingText = input.required(); + + public readonly fileSelected = output(); + + protected readonly accept = computed(() => { + return this.fileFormats() + .flatMap(({ extensions }) => { + return extensions.map((extension) => { + return `.${extension}`; + }); + }) + .join(', '); + }); + + protected readonly fileFormatListParts = computed(() => { + const fileFormats = this.fileFormats(); + + return new Intl.ListFormat(this.language(), { type: 'disjunction' }) + .formatToParts( + fileFormats.map(({ name }) => { + return name; + }) + ) + .map(({ type, value }) => { + return { + value, + sampleUrl: + type === 'element' + ? fileFormats.find(({ name }) => { + return name === value; + })?.sampleUrl + : undefined + }; + }); + }); + + public constructor() { + addIcons({ cloudUploadOutline }); + } + + protected onDragEnter(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + protected onDragOver(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + } + + protected onDrop(event: DragEvent) { + event.preventDefault(); + event.stopPropagation(); + + if (event.dataTransfer) { + // Prevent the browser's default behavior for handling the file drop + event.dataTransfer.dropEffect = 'copy'; + this.emitFirstFile(event.dataTransfer.files); + } + } + + protected onPaste(event: ClipboardEvent) { + if (this.isLoading()) { + return; + } + + const files = event.clipboardData?.files; + + if (files?.length) { + event.preventDefault(); + this.emitFirstFile(files); + } + } + + protected onSelectFile() { + const fileInputElement = document.createElement('input'); + + fileInputElement.accept = this.accept(); + fileInputElement.type = 'file'; + + fileInputElement.onchange = (event) => { + const files = (event.target as HTMLInputElement).files; + + if (files) { + this.emitFirstFile(files); + } + }; + + fileInputElement.click(); + } + + private emitFirstFile(files: FileList) { + const file = files.item(0); + + if (file) { + this.fileSelected.emit(file); + } + } +} diff --git a/libs/ui/src/lib/file-drop-area/index.ts b/libs/ui/src/lib/file-drop-area/index.ts new file mode 100644 index 0000000000..b5cf734153 --- /dev/null +++ b/libs/ui/src/lib/file-drop-area/index.ts @@ -0,0 +1,2 @@ +export * from './file-drop-area.component'; +export * from './interfaces/interfaces'; diff --git a/libs/ui/src/lib/file-drop-area/interfaces/interfaces.ts b/libs/ui/src/lib/file-drop-area/interfaces/interfaces.ts new file mode 100644 index 0000000000..695a3de654 --- /dev/null +++ b/libs/ui/src/lib/file-drop-area/interfaces/interfaces.ts @@ -0,0 +1,5 @@ +export interface FileFormat { + extensions: string[]; + name: string; + sampleUrl?: string; +} From 853914ca140f61ff0c19f64db50d14cbb2ab001b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:40:18 +0200 Subject: [PATCH 02/12] Bugfix/localization of date pickers (#8232) * Fix localization of date pickers * Update changelog --- CHANGELOG.md | 5 +++++ apps/client/src/app/app.component.ts | 18 +++++++++++++++++- ...create-or-update-access-dialog.component.ts | 6 +----- ...eate-or-update-activity-dialog.component.ts | 13 +------------ .../account-balances.component.ts | 9 +-------- ...ical-market-data-editor-dialog.component.ts | 11 +---------- 6 files changed, 26 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e34aa4caa..c31fecf7b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the performance of the historical market data table of the admin control panel by loading only the latest market price per asset profile - Introduced a cooldown of 12 hours for the asset profile and exchange rate gathering on activity creation by retaining the completed jobs +### Fixed + +- Fixed the missing localization of the date picker in the _FIRE_ calculator +- Fixed the missing localization of the date picker in the asset profile details dialog of the admin control panel + ## 3.88.0 - 2026-10-17 ### Added diff --git a/apps/client/src/app/app.component.ts b/apps/client/src/app/app.component.ts index c9e124723d..53d3505a1c 100644 --- a/apps/client/src/app/app.component.ts +++ b/apps/client/src/app/app.component.ts @@ -25,6 +25,7 @@ import { OnInit } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { DateAdapter } from '@angular/material/core'; import { MatDialog } from '@angular/material/dialog'; import { Title } from '@angular/platform-browser'; import { @@ -39,7 +40,7 @@ import { Chart } from 'chart.js'; import { addIcons } from 'ionicons'; import { openOutline } from 'ionicons/icons'; import { DeviceDetectorService } from 'ngx-device-detector'; -import { filter } from 'rxjs/operators'; +import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { GfFooterComponent } from './components/footer/footer.component'; import { GfHeaderComponent } from './components/header/header.component'; @@ -75,6 +76,7 @@ export class GfAppComponent implements OnInit { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); + private readonly dateAdapter = inject>(DateAdapter); private readonly destroyRef = inject(DestroyRef); private readonly deviceDetectorService = inject(DeviceDetectorService); private readonly dialog = inject(MatDialog); @@ -212,6 +214,20 @@ export class GfAppComponent implements OnInit { this.changeDetectorRef.markForCheck(); }); + this.userService.stateChanged + .pipe( + map(({ user }) => { + return user?.settings.locale; + }), + distinctUntilChanged(), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((locale) => { + if (locale) { + this.dateAdapter.setLocale(locale); + } + }); + this.userService.stateChanged .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe((state) => { diff --git a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts index afb0e2cea3..17d671c9b2 100644 --- a/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts +++ b/apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts @@ -1,5 +1,5 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { DEFAULT_LOCALE, MCP_ENDPOINT } from '@ghostfolio/common/config'; +import { MCP_ENDPOINT } from '@ghostfolio/common/config'; import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; import { canApplyFiltersToAccess } from '@ghostfolio/common/helper'; import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; @@ -48,7 +48,6 @@ import { Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { DateAdapter } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, @@ -108,7 +107,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { private readonly data = inject(MAT_DIALOG_DATA); - private readonly dateAdapter = inject>(DateAdapter); private readonly dataService = inject(DataService); private readonly destroyRef = inject(DestroyRef); @@ -189,8 +187,6 @@ export class GfCreateOrUpdateAccessDialogComponent implements OnInit { this.hasExperimentalFeatures = settings.isExperimentalFeatures ?? false; this.tags = getTagFilters(tags); - this.dateAdapter.setLocale(settings.locale ?? DEFAULT_LOCALE); - this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts index c041b565aa..d3bc34acee 100644 --- a/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts +++ b/apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts @@ -6,10 +6,7 @@ import { DEFAULT_LOCALE } from '@ghostfolio/common/config'; import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; -import { - getDateFormatString, - getStringOrNull -} from '@ghostfolio/common/helper'; +import { getStringOrNull } from '@ghostfolio/common/helper'; import { AssetClassSelectorOption, LookupItem @@ -41,7 +38,6 @@ import { } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, @@ -102,7 +98,6 @@ export class GfCreateOrUpdateActivityDialogComponent { protected currencies: string[] = []; protected currencyOfAssetProfile: string | undefined; protected currentMarketPrice: number | null = null; - protected defaultDateFormat: string; protected defaultLookupItems: LookupItem[] = []; protected hasPermissionToCreateOwnTag: boolean; protected isLoading = false; @@ -119,7 +114,6 @@ export class GfCreateOrUpdateActivityDialogComponent { private readonly changeDetectorRef = inject(ChangeDetectorRef); private readonly dataService = inject(DataService); - private readonly dateAdapter = inject>(DateAdapter); private readonly destroyRef = inject(DestroyRef); private readonly dialogRef = inject>(MatDialogRef); @@ -127,7 +121,6 @@ export class GfCreateOrUpdateActivityDialogComponent { private readonly impersonationStorageService = inject( ImpersonationStorageService ); - private locale = inject(MAT_DATE_LOCALE); private readonly userService = inject(UserService); public constructor() { @@ -143,15 +136,11 @@ export class GfCreateOrUpdateActivityDialogComponent { !this.impersonationStorageService.getId() && hasPermission(this.data.user?.permissions, permissions.createOwnTag); - this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE; this.mode = this.data.activity?.id ? 'update' : 'create'; - this.dateAdapter.setLocale(this.locale); - const { currencies } = this.dataService.fetchInfo(); this.currencies = currencies; - this.defaultDateFormat = getDateFormatString(this.locale); this.dataService .fetchPortfolioHoldings({ diff --git a/libs/ui/src/lib/account-balances/account-balances.component.ts b/libs/ui/src/lib/account-balances/account-balances.component.ts index 678650b2cb..715b4af0ec 100644 --- a/libs/ui/src/lib/account-balances/account-balances.component.ts +++ b/libs/ui/src/lib/account-balances/account-balances.component.ts @@ -11,7 +11,6 @@ import { Component, EventEmitter, OnChanges, - OnInit, Output, effect, inject, @@ -25,7 +24,6 @@ import { ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { DateAdapter } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -63,7 +61,7 @@ import { GfValueComponent } from '../value'; styleUrls: ['./account-balances.component.scss'], templateUrl: './account-balances.component.html' }) -export class GfAccountBalancesComponent implements OnChanges, OnInit { +export class GfAccountBalancesComponent implements OnChanges { @Output() accountBalanceCreated = new EventEmitter(); @Output() accountBalanceDeleted = new EventEmitter(); @@ -94,7 +92,6 @@ export class GfAccountBalancesComponent implements OnChanges, OnInit { >(); public maxDate = new Date(); - private dateAdapter = inject>(DateAdapter); private notificationService = inject(NotificationService); public constructor() { @@ -112,10 +109,6 @@ export class GfAccountBalancesComponent implements OnChanges, OnInit { }); } - public ngOnInit() { - this.dateAdapter.setLocale(this.locale()); - } - public ngOnChanges() { if (this.accountBalances()) { this.dataSource = new MatTableDataSource(this.accountBalances()); diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts index 4f8540974f..358dbbbcd4 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts @@ -6,7 +6,6 @@ import { Component, CUSTOM_ELEMENTS_SCHEMA, DestroyRef, - OnInit, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -17,7 +16,6 @@ import { Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MAT_DIALOG_DATA, @@ -50,7 +48,7 @@ import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces' styleUrls: ['./historical-market-data-editor-dialog.scss'], templateUrl: 'historical-market-data-editor-dialog.html' }) -export class GfHistoricalMarketDataEditorDialogComponent implements OnInit { +export class GfHistoricalMarketDataEditorDialogComponent { public readonly data = inject(MAT_DIALOG_DATA); @@ -60,23 +58,16 @@ export class GfHistoricalMarketDataEditorDialogComponent implements OnInit { }); private readonly destroyRef = inject(DestroyRef); - private readonly locale = - this.data.user.settings.locale ?? inject(MAT_DATE_LOCALE); public constructor( private adminService: AdminService, private changeDetectorRef: ChangeDetectorRef, private dataService: DataService, - private dateAdapter: DateAdapter, public dialogRef: MatDialogRef ) { addIcons({ calendarClearOutline, refreshOutline }); } - public ngOnInit() { - this.dateAdapter.setLocale(this.locale); - } - public onCancel() { this.dialogRef.close({ withRefresh: false }); } From 892c0e0aab65182de184419b74c36c0186049074 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:47:31 +0200 Subject: [PATCH 03/12] Bugfix/missing tooltip of client of last request in users table (#8230) * Fix missing tooltip * Update changelog --- CHANGELOG.md | 1 + apps/client/src/app/components/admin-users/admin-users.html | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c31fecf7b9..73207ee4ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed the missing localization of the date picker in the _FIRE_ calculator - Fixed the missing localization of the date picker in the asset profile details dialog of the admin control panel +- Fixed the missing tooltip of the client of the last request in the users table of the admin control panel ## 3.88.0 - 2026-10-17 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 7b49dc5f60..98056f00f7 100644 --- a/apps/client/src/app/components/admin-users/admin-users.html +++ b/apps/client/src/app/components/admin-users/admin-users.html @@ -199,7 +199,7 @@
@if (element.lastRequestClient) { From 7e54ba65929de0ef639830e2139366f96b600602 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:46:13 +0200 Subject: [PATCH 04/12] Task/extend app module spec by route snapshot (#8227) Extend app module spec by route snapshot --- .../app/__snapshots__/app.module.spec.ts.snap | 1183 +++++++++++++++++ apps/api/src/app/app.module.spec.ts | 212 ++- 2 files changed, 1372 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/app/__snapshots__/app.module.spec.ts.snap diff --git a/apps/api/src/app/__snapshots__/app.module.spec.ts.snap b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap new file mode 100644 index 0000000000..34cdbb907a --- /dev/null +++ b/apps/api/src/app/__snapshots__/app.module.spec.ts.snap @@ -0,0 +1,1183 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AppModule should keep the security of each route 1`] = ` +{ + "DELETE /access/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteAccess", + }, + "DELETE /account-balance/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "deleteAccountBalance", + "scopes": [ + "account:update", + ], + }, + "DELETE /account/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "deleteAccount", + "scopes": [ + "account:delete", + ], + }, + "DELETE /activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "deleteActivity", + "scopes": [ + "activity:delete", + ], + }, + "DELETE /activities/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "deleteActivity", + "scopes": [ + "activity:delete", + ], + }, + "DELETE /admin/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /admin/queue/job": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /admin/queue/job/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /asset-profiles/:dataSource/:symbol/splits/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "DELETE /auth-device/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteAuthDevice", + }, + "DELETE /benchmarks/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "DELETE /platform/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deletePlatform", + }, + "DELETE /tags/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteTag", + }, + "DELETE /user": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + ], + "interceptors": [], + "permission": "deleteOwnUser", + }, + "DELETE /user/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "deleteUser", + }, + "DELETE /watchlist/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "deleteWatchlistItem", + "scopes": [ + "watchlist:delete", + ], + }, + "GET /access": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /account": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "account:read", + ], + }, + "GET /account/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "account:read", + ], + }, + "GET /account/:id/balances": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "account:read", + ], + }, + "GET /activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "activity:read", + ], + }, + "GET /activities/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "activity:read", + ], + }, + "GET /admin": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/demo-user/sync": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "syncDemoUserAccount", + }, + "GET /admin/queue/job": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/queue/job/:id/execute": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/user": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /admin/user/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /ai/prompt/:mode": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "readAiPrompt", + "scopes": [ + "portfolio:read", + ], + }, + "GET /asset-profiles": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "GET /asset-profiles/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /asset/:dataSource/:symbol": { + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /assets/:languageCode/site.webmanifest (version neutral)": { + "guards": [], + "interceptors": [], + }, + "GET /auth/google": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('google')", + ], + "interceptors": [], + }, + "GET /auth/google/callback (version neutral)": { + "allowDuringImpersonation": true, + "guards": [ + "OAuthCallbackGuardMixin('google')", + ], + "interceptors": [], + }, + "GET /auth/oidc (version neutral)": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('oidc')", + ], + "interceptors": [], + }, + "GET /auth/oidc/callback (version neutral)": { + "allowDuringImpersonation": true, + "guards": [ + "OAuthCallbackGuardMixin('oidc')", + ], + "interceptors": [], + }, + "GET /auth/webauthn/generate-registration-options": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /benchmarks": { + "allowDuringImpersonation": true, + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /benchmarks/:dataSource/:symbol/:startDateString": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /data-providers/ghostfolio/asset-profile/:symbol": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/dividends/:symbol (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/historical/:symbol (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/lookup (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/markets": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/quotes (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /data-providers/ghostfolio/status (version 2)": { + "guards": [ + "AuthGuard('api-key')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "enableDataProviderGhostfolio", + }, + "GET /exchange-rate/:symbol/:dateString": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /export": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /health": { + "guards": [], + "interceptors": [], + }, + "GET /health/ai": { + "guards": [], + "interceptors": [], + }, + "GET /health/data-enhancer/:name": { + "guards": [], + "interceptors": [], + }, + "GET /health/data-provider/:dataSource": { + "guards": [], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /health/liveness": { + "guards": [], + "interceptors": [], + }, + "GET /import/dividends/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /info": { + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + }, + "GET /logo": { + "guards": [], + "interceptors": [], + }, + "GET /logo/:dataSource/:symbol": { + "guards": [], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /market-data/markets": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readMarketDataOfMarkets", + }, + "GET /platform": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readPlatformsWithAccountCount", + }, + "GET /platforms": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readPlatforms", + }, + "GET /portfolio/details": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/dividends": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/holding/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/holdings": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/investments": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/performance (version 2)": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "PerformanceLoggingInterceptor", + ], + "scopes": [ + "portfolio:read", + ], + }, + "GET /portfolio/report": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [], + "scopes": [ + "portfolio:read", + ], + }, + "GET /public/:accessId/portfolio": { + "guards": [], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "RedactValuesInResponseInterceptor", + ], + }, + "GET /sitemap.xml (version neutral)": { + "guards": [], + "interceptors": [], + }, + "GET /subscription/stripe/callback": { + "allowDuringImpersonation": true, + "guards": [], + "interceptors": [], + }, + "GET /symbol/:dataSource/:symbol": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + }, + "GET /symbol/:dataSource/:symbol/:dateString": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "GET /symbol/lookup": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + }, + "GET /tags": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "readTags", + }, + "GET /user": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "RedactValuesInResponseInterceptor", + ], + }, + "GET /watchlist": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + "permission": "readWatchlist", + "scopes": [ + "watchlist:read", + ], + }, + "PATCH /admin/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "PATCH /asset-profiles/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /access": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "createAccess", + }, + "POST /account": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "createAccount", + "scopes": [ + "account:create", + ], + }, + "POST /account-balance": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "createAccountBalance", + "scopes": [ + "account:update", + ], + }, + "POST /account/transfer-balance": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [], + "permission": "updateAccount", + "scopes": [ + "account:update", + ], + }, + "POST /activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "permission": "createActivity", + "scopes": [ + "activity:create", + ], + }, + "POST /admin/gather": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/:dataSource/:symbol/:dateString": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/max": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/profile-data": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/gather/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/market-data/:dataSource/:symbol/test": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /admin/profile-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "accessAdminControl", + }, + "POST /admin/profile-data/:dataSource/:symbol/merge": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "accessAdminControl", + }, + "POST /ai/activities": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "CustomThrottlerGuard", + ], + "interceptors": [ + "MixinInterceptor", + ], + "permission": "enableGhostfolioAi", + }, + "POST /api-keys": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "createApiKey", + }, + "POST /asset-profiles/:dataSource/:symbol/splits": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + }, + "POST /auth/anonymous": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /auth/webauthn/generate-authentication-options": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /auth/webauthn/verify-attestation": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "POST /auth/webauthn/verify-authentication": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /benchmarks": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /cache/flush": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /import": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + "TransformDataSourceInRequestInterceptor", + ], + "permission": "createActivity", + }, + "POST /market-data/:dataSource/:symbol": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [], + }, + "POST /platform": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "createPlatform", + }, + "POST /subscription/redeem-coupon": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "CustomThrottlerGuard", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "POST /subscription/stripe/checkout-session": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "CustomThrottlerGuard", + "HasPermissionGuard", + ], + "interceptors": [], + }, + "POST /tags": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + ], + "interceptors": [], + }, + "POST /user": { + "allowDuringImpersonation": true, + "guards": [ + "CustomThrottlerGuard", + ], + "interceptors": [], + }, + "POST /user/:id/access-token": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "POST /user/access-token": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updateOwnAccessToken", + }, + "POST /watchlist": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "createWatchlistItem", + "scopes": [ + "watchlist:create", + ], + }, + "PUT /access/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updateAccess", + }, + "PUT /account/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "RedactValuesInResponseInterceptor", + ], + "permission": "updateAccount", + "scopes": [ + "account:update", + ], + }, + "PUT /activities/:id": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + "RedactValuesInResponseInterceptor", + ], + "permission": "updateActivity", + "scopes": [ + "activity:update", + ], + }, + "PUT /admin/settings/:key": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "accessAdminControl", + }, + "PUT /platform/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updatePlatform", + }, + "PUT /portfolio/holding/:dataSource/:symbol/tags": { + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + "ScopeGuard", + ], + "interceptors": [ + "TransformDataSourceInRequestInterceptor", + ], + "permission": "updateActivity", + "scopes": [ + "activity:update", + ], + }, + "PUT /tags/:id": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + ], + "interceptors": [], + "permission": "updateTag", + }, + "PUT /user/setting": { + "allowDuringImpersonation": true, + "guards": [ + "AuthGuard('jwt')", + "HasPermissionGuard", + "ImpersonationGuard", + ], + "interceptors": [ + "TransformDataSourceInResponseInterceptor", + ], + }, +} +`; diff --git a/apps/api/src/app/app.module.spec.ts b/apps/api/src/app/app.module.spec.ts index 976e08393b..f04485ccdd 100644 --- a/apps/api/src/app/app.module.spec.ts +++ b/apps/api/src/app/app.module.spec.ts @@ -1,17 +1,26 @@ +import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; +import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; + import { DynamicModule, ForwardReference, RequestMethod, - Type + Type, + VERSION_NEUTRAL } from '@nestjs/common'; import { GUARDS_METADATA, + INTERCEPTORS_METADATA, METHOD_METADATA, MODULE_METADATA, - PATH_METADATA + PATH_METADATA, + VERSION_METADATA } from '@nestjs/common/constants'; +import { VersionValue } from '@nestjs/common/interfaces'; import { MetadataScanner } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; +import { isUndefined, omitBy } from 'lodash-es'; import { AppModule } from './app.module'; @@ -26,6 +35,13 @@ jest.mock('ai', () => { type ModuleDefinition = DynamicModule | ForwardReference<() => Type> | Promise | Type; +/** + * The strategies of the authentication which the routes apply via + * AuthGuard(). The class of such a guard has a random name, hence the + * snapshot of the routes names it by its strategy + */ +const AUTHENTICATION_STRATEGIES = ['api-key', 'google', 'jwt', 'oidc']; + /** * The routes of the controllers which answer a request without the * authentication of a user or of an API key, sorted by the request method and @@ -98,6 +114,57 @@ async function getControllers( return [...new Set(controllers)]; } +/** + * Gives the guards or the interceptors which the decorators of the controller + * and of the route handler set, in the order in which they run + */ +function getEnhancers(key: string, controller: Type, routeHandler: object) { + return [controller, routeHandler].flatMap((target) => { + return (Reflect.getMetadata(key, target) ?? []) as Type[]; + }); +} + +/** + * Gives the metadata of the route handler or, if it has none, of the + * controller, as Reflector.getAllAndOverride() does + */ +function getMetadataOfRoute( + key: string, + controller: Type, + routeHandler: object +) { + return (Reflect.getMetadata(key, routeHandler) ?? + Reflect.getMetadata(key, controller)) as T | undefined; +} + +/** + * Gives the name of a guard or of an interceptor. The function mixin() + * replaces the name of a class by a random one, hence the name is read from + * the declaration of the class. A guard which extends AuthGuard() is named + * with its strategy + */ +function getName(enhancer: Type) { + const authenticationStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => { + return AuthGuard(strategy) === enhancer; + }); + + if (authenticationStrategy) { + return `AuthGuard('${authenticationStrategy}')`; + } + + const name = /^class\s+([\w$]+)/.exec(enhancer.toString())?.[1]; + + if (!name || name === 'MixinAuthGuard') { + throw new Error('The name of a guard or of an interceptor is unknown'); + } + + const parentStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => { + return Object.getPrototypeOf(enhancer) === AuthGuard(strategy); + }); + + return parentStrategy ? `${name}('${parentStrategy}')` : name; +} + /** * Gives the paths which the decorator of a controller or of a route sets */ @@ -108,14 +175,10 @@ function getPaths(target: object) { } /** - * Gives the routes of the controller which apply neither AuthGuard('api-key') - * nor AuthGuard('jwt'), each as the request method and the path + * Gives the routes of the controller, each as the request method and the path + * with its route handler */ -function getRoutesWithoutAuthentication(controller: Type) { - const authenticationGuards: unknown[] = [ - AuthGuard('api-key'), - AuthGuard('jwt') - ]; +function getRoutes(controller: Type) { const routeHandlersByName = controller.prototype as Record; return new MetadataScanner() @@ -131,19 +194,6 @@ function getRoutesWithoutAuthentication(controller: Type) { return []; } - const guards = [controller, routeHandler].flatMap((target) => { - return (Reflect.getMetadata(GUARDS_METADATA, target) ?? - []) as unknown[]; - }); - - const hasAuthentication = guards.some((guard) => { - return authenticationGuards.includes(guard); - }); - - if (hasAuthentication) { - return []; - } - return getPaths(controller).flatMap((controllerPath) => { return getPaths(routeHandler).map((routePath) => { const path = `${controllerPath}/${routePath}` @@ -151,12 +201,110 @@ function getRoutesWithoutAuthentication(controller: Type) { .filter(Boolean) .join('/'); - return `${RequestMethod[requestMethod]} /${path}`; + return { + routeHandler, + route: `${RequestMethod[requestMethod]} /${path}` + }; }); }); }); } +/** + * Gives the routes of the controller which apply neither AuthGuard('api-key') + * nor AuthGuard('jwt'), each as the request method and the path + */ +function getRoutesWithoutAuthentication(controller: Type) { + const authenticationGuards: unknown[] = [ + AuthGuard('api-key'), + AuthGuard('jwt') + ]; + + return getRoutes(controller) + .filter(({ routeHandler }) => { + const hasAuthentication = getEnhancers( + GUARDS_METADATA, + controller, + routeHandler + ).some((guard) => { + return authenticationGuards.includes(guard); + }); + + return !hasAuthentication; + }) + .map(({ route }) => { + return route; + }); +} + +/** + * Gives the routes of the controller, each as the request method, the path + * and the version with the guards and the interceptors in the order in which + * they run, the permission, the scopes and whether the route stays available + * while an impersonation is active + */ +function getRoutesWithSecurity(controller: Type) { + return getRoutes(controller).map(({ route, routeHandler }) => { + const security = omitBy( + { + allowDuringImpersonation: getMetadataOfRoute( + ALLOW_DURING_IMPERSONATION_KEY, + controller, + routeHandler + ), + guards: getEnhancers(GUARDS_METADATA, controller, routeHandler).map( + (guard) => { + return getName(guard); + } + ), + interceptors: getEnhancers( + INTERCEPTORS_METADATA, + controller, + routeHandler + ).map((interceptor) => { + return getName(interceptor); + }), + // HasPermissionGuard reads the permission of the route handler only + permission: Reflect.getMetadata(HAS_PERMISSION_KEY, routeHandler) as + string | undefined, + scopes: getMetadataOfRoute( + REQUIRES_SCOPE_KEY, + controller, + routeHandler + ) + }, + isUndefined + ); + + return [ + `${route}${getVersionSuffix(controller, routeHandler)}`, + security + ] as const; + }); +} + +/** + * Gives the version of the route as a suffix of its key, which is empty for + * the default version + */ +function getVersionSuffix(controller: Type, routeHandler: object) { + const version = getMetadataOfRoute( + VERSION_METADATA, + controller, + routeHandler + ); + + if (version === undefined) { + return ''; + } + + const versions = [version].flat().map((value) => { + return value === VERSION_NEUTRAL ? 'neutral' : value; + }); + + return ` (version ${versions.join(', ')})`; +} + describe('AppModule', () => { it('should require the authentication for each route which is not public', async () => { const controllers = await getControllers(AppModule); @@ -169,4 +317,22 @@ describe('AppModule', () => { expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES); }); + + // An intended change of a route is confirmed by an update of the snapshot + // (--updateSnapshot), hence each change shows in the review + it('should keep the security of each route', async () => { + const controllers = await getControllers(AppModule); + + const routesWithSecurity = controllers.flatMap((controller) => { + return getRoutesWithSecurity(controller); + }); + + const routes = routesWithSecurity.map(([route]) => { + return route; + }); + + // The snapshot is an object, which would hide a route declared twice + expect(new Set(routes).size).toBe(routes.length); + expect(Object.fromEntries(routesWithSecurity)).toMatchSnapshot(); + }); }); From 6157d2bd53932d049c0afbd6960fe8d87a496e83 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:02:52 +0200 Subject: [PATCH 05/12] Task/remove unused user in historical market data editor (#8243) Remove unused user --- .../asset-profile-dialog/asset-profile-dialog.html | 1 - .../holding-detail-dialog/holding-detail-dialog.html | 1 - .../interfaces/interfaces.ts | 2 -- .../historical-market-data-editor.component.ts | 6 ++---- 4 files changed, 2 insertions(+), 8 deletions(-) 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 0b9e038a02..8e6341a709 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 @@ -505,7 +505,6 @@ [locale]="data.locale" [marketData]="marketDataItems" [symbol]="data.symbol" - [user]="user" (marketDataChanged)="onMarketDataChanged($event)" />
diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html index efb693e59a..de56a7b003 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html @@ -437,7 +437,6 @@ [locale]="data.locale" [marketData]="marketDataItems" [symbol]="assetProfile?.symbol" - [user]="user" (marketDataChanged)="onMarketDataChanged($event)" /> diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts index 4a0e419e7b..4803071ac7 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts @@ -1,4 +1,3 @@ -import { User } from '@ghostfolio/common/interfaces'; import { DataSource } from '@ghostfolio/prisma/enums'; export interface HistoricalMarketDataEditorDialogParams { @@ -7,5 +6,4 @@ export interface HistoricalMarketDataEditorDialogParams { dateString: string; marketPrice?: number; symbol: string; - user: User; } diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts index 94baafbf1a..cefc96b8b2 100644 --- a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts @@ -4,7 +4,7 @@ import { getDateFormatString, getLocale } from '@ghostfolio/common/helper'; -import { LineChartItem, User } from '@ghostfolio/common/interfaces'; +import { LineChartItem } from '@ghostfolio/common/interfaces'; import type { MarketData } from '@ghostfolio/prisma/browser'; import { DataSource } from '@ghostfolio/prisma/enums'; import { DataService } from '@ghostfolio/ui/services'; @@ -70,7 +70,6 @@ export class GfHistoricalMarketDataEditorComponent @Input() dataSource: DataSource; @Input() dateOfFirstActivity?: Date; @Input() symbol: string; - @Input() user: User; @Output() marketDataChanged = new EventEmitter(); @@ -233,8 +232,7 @@ export class GfHistoricalMarketDataEditorComponent currency: this.currency, dataSource: this.dataSource, dateString: `${yearMonth}-${day}`, - symbol: this.symbol, - user: this.user + symbol: this.symbol }, height: this.deviceType() === 'mobile' ? '98vh' : '80vh', width: this.deviceType() === 'mobile' ? '100vw' : '50rem' From 62b1ea937fdcd2775ebd66e2af3980ef9a9b8948 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:25:12 +0200 Subject: [PATCH 06/12] Task/harmonize pointer events override of ion icon (#8254) Harmonize pointer events override --- .../portfolio-performance.component.html | 1 + .../portfolio-performance.component.scss | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html index 9be2be344e..c458080e3c 100644 --- a/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html +++ b/apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html @@ -3,6 +3,7 @@
@if ((errors()?.length ?? 0) > 0 && !isLoading()) { Date: Tue, 20 Oct 2026 19:26:21 +0200 Subject: [PATCH 07/12] Task/improve loading state of file drop area component (#8250) * Improve loading state * Update changelog --- CHANGELOG.md | 1 + .../file-drop-area.component.html | 13 +-- .../file-drop-area.component.scss | 94 ++++++++++++++++--- .../file-drop-area.component.stories.ts | 6 -- .../file-drop-area.component.ts | 3 +- 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73207ee4ce..11cae3d5e6 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 usability of the activities page by eliminating the reload on canceling the import activities dialog +- Improved the loading state of the file drop area component - Improved the accessibility of the _Buy me a coffee_ button on the about page - Improved the accessibility of the entity logo component - Extracted the file drop area of the import activities dialog to a reusable component diff --git a/libs/ui/src/lib/file-drop-area/file-drop-area.component.html b/libs/ui/src/lib/file-drop-area/file-drop-area.component.html index b5c6e19021..a4d1d6c91e 100644 --- a/libs/ui/src/lib/file-drop-area/file-drop-area.component.html +++ b/libs/ui/src/lib/file-drop-area/file-drop-area.component.html @@ -1,13 +1,14 @@ @if (isLoading()) { -
- - {{ loadingText() }} +
+
+ {{ loadingText() }} +
} @else {