Browse Source

Merge branch 'main' into Task/testimprovement_authservice

pull/6015/head
Sven Günther 2 days ago
committed by GitHub
parent
commit
9cb750e6d5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 16
      CHANGELOG.md
  2. 21
      apps/api/src/app/admin/admin.service.ts
  3. 2
      apps/api/src/app/subscription/subscription.service.ts
  4. 7
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  5. 19
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  6. 6
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  7. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  8. 28
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  9. 6
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  10. 102
      apps/client/src/app/pages/blog/blog-page.routes.ts
  11. 8
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  12. 6
      apps/client/src/app/pages/resources/resources-page.routes.ts
  13. 3
      libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-asset-profile-response.interface.ts
  14. 75
      libs/common/src/lib/interfaces/simplewebauthn.interface.ts
  15. 3
      libs/common/src/lib/types/subscription-offer-key.type.ts
  16. 4
      libs/common/src/lib/validators/is-currency-code.ts
  17. 6
      libs/ui/src/lib/assistant/interfaces/interfaces.ts
  18. 2
      libs/ui/src/lib/entity-logo/entity-logo.component.html
  19. 94
      libs/ui/src/lib/membership-card/membership-card.component.html
  20. 244
      libs/ui/src/lib/membership-card/membership-card.component.scss
  21. 5
      libs/ui/src/lib/membership-card/membership-card.component.stories.ts
  22. 1
      libs/ui/src/lib/membership-card/membership-card.component.ts
  23. 20
      package-lock.json
  24. 6
      package.json

16
CHANGELOG.md

@ -7,9 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
#### Added
- improved test coverage for AuthService
- Introduced data source transformation support in the import functionality for self-hosted environments
- Added an optional 3D hover effect to the membership card component
#### Changed
- Increased the numerical precision for cryptocurrency quantities in the holding detail dialog
- Upgraded `envalid` from version `8.1.0` to `8.1.1`
- Upgraded `prettier` from version `3.7.3` to `3.7.4`
## 2.221.0 - 2025-12-01
### Changed
@ -21,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Improved the country weightings in the _Financial Modeling Prep_ service
- Improved the search functionality by name in the _Financial Modeling Prep_ service
- Resolved an issue in the user endpoint where the list was returning empty in the admin control panel’s users section
## 2.220.0 - 2025-11-29
@ -2256,7 +2268,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Fixed an issue in the portfolio summary with the currency conversion of fees
- Fixed an issue in the the search for a holding
- Fixed an issue in the search for a holding
- Removed the show condition of the experimental features setting in the user settings
## 2.95.0 - 2024-07-12

21
apps/api/src/app/admin/admin.service.ts

@ -532,12 +532,7 @@ export class AdminService {
this.countUsersWithAnalytics(),
this.getUsersWithAnalytics({
skip,
take,
where: {
NOT: {
analytics: null
}
}
take
})
]);
@ -855,6 +850,20 @@ export class AdminService {
}
}
];
const noAnalyticsCondition: Prisma.UserWhereInput['NOT'] = {
analytics: null
};
if (where) {
if (where.NOT) {
where.NOT = { ...where.NOT, ...noAnalyticsCondition };
} else {
where.NOT = noAnalyticsCondition;
}
} else {
where = { NOT: noAnalyticsCondition };
}
}
const usersWithAnalytics = await this.prismaService.user.findMany({

2
apps/api/src/app/subscription/subscription.service.ts

@ -179,6 +179,8 @@ export class SubscriptionService {
offerKey = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offerKey = 'renewal-early-bird-2024';
} else if (isBefore(createdAt, parseDate('2025-12-01'))) {
offerKey = 'renewal-early-bird-2025';
}
const offer = await this.getSubscriptionOffer({

7
apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts

@ -16,9 +16,10 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
T,
any
> {
public intercept(
context: ExecutionContext,
next: CallHandler<T>

19
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -11,9 +11,9 @@ import { DataSource } from '@prisma/client';
import { Observable } from 'rxjs';
@Injectable()
export class TransformDataSourceInRequestInterceptor<T>
implements NestInterceptor<T, any>
{
export class TransformDataSourceInRequestInterceptor<
T
> implements NestInterceptor<T, any> {
public constructor(
private readonly configurationService: ConfigurationService
) {}
@ -69,6 +69,19 @@ export class TransformDataSourceInRequestInterceptor<T>
});
}
}
} else {
if (request.body?.activities) {
request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) {
return activity;
} else {
return {
...activity,
dataSource: decodeDataSource(activity.dataSource)
};
}
});
}
}
return next.handle();

6
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -13,9 +13,9 @@ import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformDataSourceInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
export class TransformDataSourceInResponseInterceptor<
T
> implements NestInterceptor<T, any> {
private encodedDataSourceMap: {
[dataSource: string]: string;
} = {};

2
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts

@ -317,7 +317,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
return { assetClass, assetSubClass };
}
private parseSector(aString: string): string {
private parseSector(aString: string) {
let sector = UNKNOWN_KEY;
switch (aString) {

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

@ -41,6 +41,7 @@ import {
isSameDay,
parseISO
} from 'date-fns';
import { uniqBy } from 'lodash';
@Injectable()
export class FinancialModelingPrepService implements DataProviderInterface {
@ -549,14 +550,27 @@ export class FinancialModelingPrepService implements DataProviderInterface {
apikey: this.apiKey
});
const result = await fetch(
`${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
const [nameResults, symbolResults] = await Promise.all([
fetch(
`${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json()),
fetch(
`${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json())
]);
const result = uniqBy(
[...nameResults, ...symbolResults],
({ exchange, symbol }) => {
return `${exchange}-${symbol}`;
}
).then((res) => res.json());
);
items = result
.filter(({ exchange, symbol }) => {

6
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -411,10 +411,10 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
} else if (SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 1) {
this.quantityPrecision = 7;
if (this.quantity < 10) {
this.quantityPrecision = 8;
} else if (this.quantity < 1000) {
this.quantityPrecision = 5;
this.quantityPrecision = 6;
} else if (this.quantity >= 10000000) {
this.quantityPrecision = 0;
}

102
apps/client/src/app/pages/blog/blog-page.routes.ts

@ -34,117 +34,117 @@ export const routes: Routes = [
canActivate: [AuthGuard],
path: '2022/01/ghostfolio-first-months-in-open-source',
loadComponent: () =>
import(
'./2022/01/first-months-in-open-source/first-months-in-open-source-page.component'
).then((c) => c.FirstMonthsInOpenSourcePageComponent),
import('./2022/01/first-months-in-open-source/first-months-in-open-source-page.component').then(
(c) => c.FirstMonthsInOpenSourcePageComponent
),
title: 'First months in Open Source'
},
{
canActivate: [AuthGuard],
path: '2022/07/ghostfolio-meets-internet-identity',
loadComponent: () =>
import(
'./2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component'
).then((c) => c.GhostfolioMeetsInternetIdentityPageComponent),
import('./2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component').then(
(c) => c.GhostfolioMeetsInternetIdentityPageComponent
),
title: 'Ghostfolio meets Internet Identity'
},
{
canActivate: [AuthGuard],
path: '2022/07/how-do-i-get-my-finances-in-order',
loadComponent: () =>
import(
'./2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component'
).then((c) => c.HowDoIGetMyFinancesInOrderPageComponent),
import('./2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component').then(
(c) => c.HowDoIGetMyFinancesInOrderPageComponent
),
title: 'How do I get my finances in order?'
},
{
canActivate: [AuthGuard],
path: '2022/08/500-stars-on-github',
loadComponent: () =>
import(
'./2022/08/500-stars-on-github/500-stars-on-github-page.component'
).then((c) => c.FiveHundredStarsOnGitHubPageComponent),
import('./2022/08/500-stars-on-github/500-stars-on-github-page.component').then(
(c) => c.FiveHundredStarsOnGitHubPageComponent
),
title: '500 Stars on GitHub'
},
{
canActivate: [AuthGuard],
path: '2022/10/hacktoberfest-2022',
loadComponent: () =>
import(
'./2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component'
).then((c) => c.Hacktoberfest2022PageComponent),
import('./2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component').then(
(c) => c.Hacktoberfest2022PageComponent
),
title: 'Hacktoberfest 2022'
},
{
canActivate: [AuthGuard],
path: '2022/11/black-friday-2022',
loadComponent: () =>
import(
'./2022/11/black-friday-2022/black-friday-2022-page.component'
).then((c) => c.BlackFriday2022PageComponent),
import('./2022/11/black-friday-2022/black-friday-2022-page.component').then(
(c) => c.BlackFriday2022PageComponent
),
title: 'Black Friday 2022'
},
{
canActivate: [AuthGuard],
path: '2022/12/the-importance-of-tracking-your-personal-finances',
loadComponent: () =>
import(
'./2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.component'
).then((c) => c.TheImportanceOfTrackingYourPersonalFinancesPageComponent),
import('./2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.component').then(
(c) => c.TheImportanceOfTrackingYourPersonalFinancesPageComponent
),
title: 'The importance of tracking your personal finances'
},
{
canActivate: [AuthGuard],
path: '2023/01/ghostfolio-auf-sackgeld-vorgestellt',
loadComponent: () =>
import(
'./2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.component'
).then((c) => c.GhostfolioAufSackgeldVorgestelltPageComponent),
import('./2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.component').then(
(c) => c.GhostfolioAufSackgeldVorgestelltPageComponent
),
title: 'Ghostfolio auf Sackgeld.com vorgestellt'
},
{
canActivate: [AuthGuard],
path: '2023/02/ghostfolio-meets-umbrel',
loadComponent: () =>
import(
'./2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.component'
).then((c) => c.GhostfolioMeetsUmbrelPageComponent),
import('./2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.component').then(
(c) => c.GhostfolioMeetsUmbrelPageComponent
),
title: 'Ghostfolio meets Umbrel'
},
{
canActivate: [AuthGuard],
path: '2023/03/ghostfolio-reaches-1000-stars-on-github',
loadComponent: () =>
import(
'./2023/03/1000-stars-on-github/1000-stars-on-github-page.component'
).then((c) => c.ThousandStarsOnGitHubPageComponent),
import('./2023/03/1000-stars-on-github/1000-stars-on-github-page.component').then(
(c) => c.ThousandStarsOnGitHubPageComponent
),
title: 'Ghostfolio reaches 1’000 Stars on GitHub'
},
{
canActivate: [AuthGuard],
path: '2023/05/unlock-your-financial-potential-with-ghostfolio',
loadComponent: () =>
import(
'./2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.component'
).then((c) => c.UnlockYourFinancialPotentialWithGhostfolioPageComponent),
import('./2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.component').then(
(c) => c.UnlockYourFinancialPotentialWithGhostfolioPageComponent
),
title: 'Unlock your Financial Potential with Ghostfolio'
},
{
canActivate: [AuthGuard],
path: '2023/07/exploring-the-path-to-fire',
loadComponent: () =>
import(
'./2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.component'
).then((c) => c.ExploringThePathToFirePageComponent),
import('./2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.component').then(
(c) => c.ExploringThePathToFirePageComponent
),
title: 'Exploring the Path to FIRE'
},
{
canActivate: [AuthGuard],
path: '2023/08/ghostfolio-joins-oss-friends',
loadComponent: () =>
import(
'./2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component'
).then((c) => c.GhostfolioJoinsOssFriendsPageComponent),
import('./2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component').then(
(c) => c.GhostfolioJoinsOssFriendsPageComponent
),
title: 'Ghostfolio joins OSS Friends'
},
{
@ -160,9 +160,9 @@ export const routes: Routes = [
canActivate: [AuthGuard],
path: '2023/09/hacktoberfest-2023',
loadComponent: () =>
import(
'./2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component'
).then((c) => c.Hacktoberfest2023PageComponent),
import('./2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component').then(
(c) => c.Hacktoberfest2023PageComponent
),
title: 'Hacktoberfest 2023'
},
{
@ -178,18 +178,18 @@ export const routes: Routes = [
canActivate: [AuthGuard],
path: '2023/11/hacktoberfest-2023-debriefing',
loadComponent: () =>
import(
'./2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component'
).then((c) => c.Hacktoberfest2023DebriefingPageComponent),
import('./2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component').then(
(c) => c.Hacktoberfest2023DebriefingPageComponent
),
title: 'Hacktoberfest 2023 Debriefing'
},
{
canActivate: [AuthGuard],
path: '2024/09/hacktoberfest-2024',
loadComponent: () =>
import(
'./2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component'
).then((c) => c.Hacktoberfest2024PageComponent),
import('./2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component').then(
(c) => c.Hacktoberfest2024PageComponent
),
title: 'Hacktoberfest 2024'
},
{
@ -205,9 +205,9 @@ export const routes: Routes = [
canActivate: [AuthGuard],
path: '2025/09/hacktoberfest-2025',
loadComponent: () =>
import(
'./2025/09/hacktoberfest-2025/hacktoberfest-2025-page.component'
).then((c) => c.Hacktoberfest2025PageComponent),
import('./2025/09/hacktoberfest-2025/hacktoberfest-2025-page.component').then(
(c) => c.Hacktoberfest2025PageComponent
),
title: 'Hacktoberfest 2025'
},
{

8
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -54,23 +54,30 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
public durationExtension: StringValue;
public hasPermissionToCreateUser: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public importAndExportTooltipBasic = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
);
public importAndExportTooltipOSS = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS'
);
public importAndExportTooltipPremium = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM'
);
public isLoggedIn: boolean;
public label: string;
public price: number;
public priceId: string;
public professionalDataProviderTooltipPremium = translate(
'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM'
);
public referralBrokers = [
'Alpian',
'DEGIRO',
'finpension',
'frankly',
@ -80,6 +87,7 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
'VIAC',
'Zak'
];
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink;
public user: User;

6
apps/client/src/app/pages/resources/resources-page.routes.ts

@ -33,9 +33,9 @@ export const routes: Routes = [
{
path: publicRoutes.resources.subRoutes.personalFinanceTools.path,
loadChildren: () =>
import(
'./personal-finance-tools/personal-finance-tools-page.routes'
).then((m) => m.routes)
import('./personal-finance-tools/personal-finance-tools-page.routes').then(
(m) => m.routes
)
}
],
path: '',

3
libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-asset-profile-response.interface.ts

@ -1,4 +1,3 @@
import { SymbolProfile } from '@prisma/client';
export interface DataProviderGhostfolioAssetProfileResponse
extends Partial<SymbolProfile> {}
export interface DataProviderGhostfolioAssetProfileResponse extends Partial<SymbolProfile> {}

75
libs/common/src/lib/interfaces/simplewebauthn.interface.ts

@ -3,8 +3,7 @@ export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
readonly signature: ArrayBuffer;
readonly userHandle: ArrayBuffer | null;
}
export interface AuthenticatorAttestationResponse
extends AuthenticatorResponse {
export interface AuthenticatorAttestationResponse extends AuthenticatorResponse {
readonly attestationObject: ArrayBuffer;
}
export interface AuthenticationExtensionsClientInputs {
@ -57,8 +56,7 @@ export interface PublicKeyCredentialRequestOptions {
timeout?: number;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredentialUserEntity
extends PublicKeyCredentialEntity {
export interface PublicKeyCredentialUserEntity extends PublicKeyCredentialEntity {
displayName: string;
id: BufferSource;
}
@ -99,11 +97,10 @@ export declare type BufferSource = ArrayBufferView | ArrayBuffer;
export declare type PublicKeyCredentialType = 'public-key';
export declare type UvmEntry = number[];
export interface PublicKeyCredentialCreationOptionsJSON
extends Omit<
PublicKeyCredentialCreationOptions,
'challenge' | 'user' | 'excludeCredentials'
> {
export interface PublicKeyCredentialCreationOptionsJSON extends Omit<
PublicKeyCredentialCreationOptions,
'challenge' | 'user' | 'excludeCredentials'
> {
user: PublicKeyCredentialUserEntityJSON;
challenge: Base64URLString;
excludeCredentials: PublicKeyCredentialDescriptorJSON[];
@ -113,21 +110,24 @@ export interface PublicKeyCredentialCreationOptionsJSON
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
* (eventually) get passed into navigator.credentials.get(...) in the browser.
*/
export interface PublicKeyCredentialRequestOptionsJSON
extends Omit<
PublicKeyCredentialRequestOptions,
'challenge' | 'allowCredentials'
> {
export interface PublicKeyCredentialRequestOptionsJSON extends Omit<
PublicKeyCredentialRequestOptions,
'challenge' | 'allowCredentials'
> {
challenge: Base64URLString;
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs;
}
export interface PublicKeyCredentialDescriptorJSON
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
export interface PublicKeyCredentialDescriptorJSON extends Omit<
PublicKeyCredentialDescriptor,
'id'
> {
id: Base64URLString;
}
export interface PublicKeyCredentialUserEntityJSON
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
export interface PublicKeyCredentialUserEntityJSON extends Omit<
PublicKeyCredentialUserEntity,
'id'
> {
id: string;
}
/**
@ -140,11 +140,10 @@ export interface AttestationCredential extends PublicKeyCredential {
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AttestationCredentialJSON
extends Omit<
AttestationCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
export interface AttestationCredentialJSON extends Omit<
AttestationCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAttestationResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
@ -160,11 +159,10 @@ export interface AssertionCredential extends PublicKeyCredential {
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AssertionCredentialJSON
extends Omit<
AssertionCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
export interface AssertionCredentialJSON extends Omit<
AssertionCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAssertionResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
@ -173,11 +171,10 @@ export interface AssertionCredentialJSON
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAttestationResponseJSON
extends Omit<
AuthenticatorAttestationResponseFuture,
'clientDataJSON' | 'attestationObject'
> {
export interface AuthenticatorAttestationResponseJSON extends Omit<
AuthenticatorAttestationResponseFuture,
'clientDataJSON' | 'attestationObject'
> {
clientDataJSON: Base64URLString;
attestationObject: Base64URLString;
}
@ -185,11 +182,10 @@ export interface AuthenticatorAttestationResponseJSON
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAssertionResponseJSON
extends Omit<
AuthenticatorAssertionResponse,
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
> {
export interface AuthenticatorAssertionResponseJSON extends Omit<
AuthenticatorAssertionResponse,
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
> {
authenticatorData: Base64URLString;
clientDataJSON: Base64URLString;
signature: Base64URLString;
@ -217,8 +213,7 @@ export declare type Base64URLString = string;
*
* Properties marked optional are not supported in all browsers.
*/
export interface AuthenticatorAttestationResponseFuture
extends AuthenticatorAttestationResponse {
export interface AuthenticatorAttestationResponseFuture extends AuthenticatorAttestationResponse {
getTransports?: () => AuthenticatorTransport[];
getAuthenticatorData?: () => ArrayBuffer;
getPublicKey?: () => ArrayBuffer;

3
libs/common/src/lib/types/subscription-offer-key.type.ts

@ -2,4 +2,5 @@ export type SubscriptionOfferKey =
| 'default'
| 'renewal'
| 'renewal-early-bird-2023'
| 'renewal-early-bird-2024';
| 'renewal-early-bird-2024'
| 'renewal-early-bird-2025';

4
libs/common/src/lib/validators/is-currency-code.ts

@ -21,9 +21,7 @@ export function IsCurrencyCode(validationOptions?: ValidationOptions) {
}
@ValidatorConstraint({ async: false })
export class IsExtendedCurrencyConstraint
implements ValidatorConstraintInterface
{
export class IsExtendedCurrencyConstraint implements ValidatorConstraintInterface {
public defaultMessage() {
return '$property must be a valid ISO4217 currency code';
}

6
libs/ui/src/lib/assistant/interfaces/interfaces.ts

@ -3,8 +3,10 @@ import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
import { SearchMode } from '../enums/search-mode';
export interface AccountSearchResultItem
extends Pick<AccountWithValue, 'id' | 'name'> {
export interface AccountSearchResultItem extends Pick<
AccountWithValue,
'id' | 'name'
> {
mode: SearchMode.ACCOUNT;
routerLink: string[];
}

2
libs/ui/src/lib/entity-logo/entity-logo.component.html

@ -1,6 +1,6 @@
@if (src) {
<img
onerror="this.style.display='none'"
onerror="this.style.display = 'none'"
[ngClass]="{ large: size === 'large' }"
[src]="src"
[title]="tooltip ? tooltip : ''"

94
libs/ui/src/lib/membership-card/membership-card.component.html

@ -1,50 +1,54 @@
<div
class="card-container position-relative"
[ngClass]="{ premium: name === 'Premium' }"
>
<a
class="card-item d-flex flex-column justify-content-between p-4"
[routerLink]="routerLinkPricing"
>
<div class="d-flex justify-content-end">
<gf-logo
size="large"
[ngClass]="{ 'text-muted': name === 'Basic' }"
[showLabel]="false"
/>
</div>
@if (hasPermissionToCreateApiKey) {
<div class="mt-5">
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
</div>
}
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>
<div class="text-truncate value">{{ name }}</div>
<div class="card-wrapper position-relative" [class.hover-3d]="hover3d">
<div class="card-container" [ngClass]="{ premium: name === 'Premium' }">
<a
class="card-item d-flex flex-column justify-content-between p-4"
[routerLink]="routerLinkPricing"
>
@if (hover3d) {
@for (zone of [1, 2, 3, 4, 5, 6, 7, 8, 9]; track zone) {
<span class="hover-zone position-absolute"></span>
}
}
<div class="d-flex justify-content-end">
<gf-logo
size="large"
[ngClass]="{ 'text-muted': name === 'Basic' }"
[showLabel]="false"
/>
</div>
@if (expiresAt) {
<div>
<div class="heading text-muted" i18n>Valid until</div>
<div class="text-truncate value">
{{ expiresAt }}
@if (hasPermissionToCreateApiKey) {
<div class="mt-5">
<div class="heading text-muted" i18n>API Key</div>
<div class="align-items-center d-flex">
<div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1">
<button
class="bg-transparent no-min-width"
i18n-title
mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
(click)="onGenerateApiKey($event)"
>
<ion-icon name="refresh-outline" />
</button>
</div>
</div>
</div>
}
</div>
</a>
<div class="d-flex justify-content-between">
<div>
<div class="heading text-muted" i18n>Membership</div>
<div class="text-truncate value">{{ name }}</div>
</div>
@if (expiresAt) {
<div>
<div class="heading text-muted" i18n>Valid until</div>
<div class="text-truncate value">
{{ expiresAt }}
</div>
</div>
}
</div>
</a>
</div>
</div>

244
libs/ui/src/lib/membership-card/membership-card.component.scss

@ -1,71 +1,231 @@
:host {
--borderRadius: 1rem;
--borderWidth: 2px;
--hover3dSpotlightOpacity: 0.2;
display: block;
max-width: 25rem;
padding-top: calc(1 * var(--borderWidth));
width: 100%;
.card-container {
border-radius: var(--borderRadius);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
.card-wrapper {
&.hover-3d {
perspective: 1000px;
}
&:after {
animation: animatedborder 7s ease alternate infinite;
background: linear-gradient(60deg, #5073b8, #1098ad, #07b39b, #6fba82);
background-size: 300% 300%;
.card-container {
border-radius: var(--borderRadius);
content: '';
height: calc(100% + var(--borderWidth) * 2);
left: calc(-1 * var(--borderWidth));
top: calc(-1 * var(--borderWidth));
position: absolute;
width: calc(100% + var(--borderWidth) * 2);
z-index: -1;
@keyframes animatedborder {
0% {
background-position: 0% 50%;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
.card-item {
aspect-ratio: 1.586;
background-color: #1d2124;
border-radius: calc(var(--borderRadius) - var(--borderWidth));
color: rgba(var(--light-primary-text));
line-height: 1.2;
button {
color: rgba(var(--light-primary-text));
height: 1.5rem;
z-index: 3;
}
50% {
background-position: 100% 50%;
.heading {
font-size: 13px;
}
100% {
background-position: 0% 50%;
.value {
font-size: 18px;
}
}
&:not(.premium) {
&::after {
opacity: 0;
}
.card-item {
background-color: #ffffff;
color: rgba(var(--dark-primary-text));
}
}
}
.card-item {
aspect-ratio: 1.586;
background-color: #1d2124;
border-radius: calc(var(--borderRadius) - var(--borderWidth));
color: rgba(var(--light-primary-text));
line-height: 1.2;
&.hover-3d {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 100%;
button {
color: rgba(var(--light-primary-text));
height: 1.5rem;
.card-container {
overflow: hidden;
position: relative;
scale: 1;
transform: rotate3d(
var(--hover3d-rotate-x),
var(--hover3d-rotate-y),
0,
10deg
);
transform-style: preserve-3d;
transition:
box-shadow 400ms ease-out,
scale 500ms ease-out,
transform 500ms ease-out;
will-change: transform, scale;
&::before {
background-image: radial-gradient(
circle at 50%,
rgba(255, 255, 255, var(--hover3dSpotlightOpacity)) 10%,
transparent 50%
);
content: '';
filter: blur(0.75rem);
height: 33.333%;
opacity: 0;
pointer-events: none;
position: absolute;
scale: 500%;
translate: var(--hover3d-shine);
transition:
opacity 400ms ease-out,
translate 400ms ease-out;
width: 33.333%;
z-index: 1;
}
.card-item {
position: relative;
.hover-zone {
height: 33.333%;
width: 33.333%;
z-index: 2;
&:nth-child(1) {
left: 0;
top: 0;
}
&:nth-child(2) {
left: 33.333%;
top: 0;
}
&:nth-child(3) {
right: 0;
top: 0;
}
&:nth-child(4) {
left: 0;
top: 33.333%;
}
&:nth-child(5) {
left: 33.333%;
top: 33.333%;
}
&:nth-child(6) {
right: 0;
top: 33.333%;
}
&:nth-child(7) {
bottom: 0;
left: 0;
}
&:nth-child(8) {
bottom: 0;
left: 33.333%;
}
&:nth-child(9) {
bottom: 0;
right: 0;
}
}
}
}
.heading {
font-size: 13px;
&:has(.hover-zone:hover) .card-container {
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3);
scale: 1.05;
&::before {
opacity: 1;
}
}
.value {
font-size: 18px;
&:has(.hover-zone:nth-child(1):hover) {
--hover3d-rotate-x: 1;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 0%;
}
}
&:not(.premium) {
&:after {
opacity: 0;
&:has(.hover-zone:nth-child(2):hover) {
--hover3d-rotate-x: 1;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 0%;
}
.card-item {
background-color: #ffffff;
color: rgba(var(--dark-primary-text));
&:has(.hover-zone:nth-child(3):hover) {
--hover3d-rotate-x: 1;
--hover3d-rotate-y: 1;
--hover3d-shine: 200% 0%;
}
&:has(.hover-zone:nth-child(4):hover) {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 100%;
}
&:has(.hover-zone:nth-child(5):hover) {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 100%;
}
&:has(.hover-zone:nth-child(6):hover) {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: 1;
--hover3d-shine: 200% 100%;
}
&:has(.hover-zone:nth-child(7):hover) {
--hover3d-rotate-x: -1;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 200%;
}
&:has(.hover-zone:nth-child(8):hover) {
--hover3d-rotate-x: -1;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 200%;
}
&:has(.hover-zone:nth-child(9):hover) {
--hover3d-rotate-x: -1;
--hover3d-rotate-y: 1;
--hover3d-shine: 200% 200%;
}
}
}
@media (prefers-reduced-motion: reduce) {
.card-wrapper.hover-3d {
.card-container {
scale: 1 !important;
transform: none !important;
transition: none !important;
&::before {
opacity: 0 !important;
transition: none !important;
}
}
}
}

5
libs/ui/src/lib/membership-card/membership-card.component.stories.ts

@ -26,6 +26,9 @@ export default {
})
],
argTypes: {
hover3d: {
control: { type: 'boolean' }
},
name: {
control: { type: 'select' },
options: ['Basic', 'Premium']
@ -37,6 +40,7 @@ type Story = StoryObj<GfMembershipCardComponent>;
export const Basic: Story = {
args: {
hover3d: false,
name: 'Basic'
}
};
@ -45,6 +49,7 @@ export const Premium: Story = {
args: {
expiresAt: addYears(new Date(), 1).toLocaleDateString(),
hasPermissionToCreateApiKey: true,
hover3d: false,
name: 'Premium'
}
};

1
libs/ui/src/lib/membership-card/membership-card.component.ts

@ -34,6 +34,7 @@ import { GfLogoComponent } from '../logo';
export class GfMembershipCardComponent {
@Input() public expiresAt: string;
@Input() public hasPermissionToCreateApiKey: boolean;
@Input() public hover3d = false;
@Input() public name: string;
@Output() generateApiKeyClicked = new EventEmitter<void>();

20
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.220.0",
"version": "2.221.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.220.0",
"version": "2.221.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -62,7 +62,7 @@
"date-fns": "4.1.0",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"envalid": "8.1.0",
"envalid": "8.1.1",
"fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0",
"helmet": "7.0.0",
@ -140,7 +140,7 @@
"jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.6.0",
"nx": "21.5.1",
"prettier": "3.7.3",
"prettier": "3.7.4",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.19.0",
"react": "18.2.0",
@ -21332,9 +21332,9 @@
}
},
"node_modules/envalid": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/envalid/-/envalid-8.1.0.tgz",
"integrity": "sha512-OT6+qVhKVyCidaGoXflb2iK1tC8pd0OV2Q+v9n33wNhUJ+lus+rJobUj4vJaQBPxPZ0vYrPGuxdrenyCAIJcow==",
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/envalid/-/envalid-8.1.1.tgz",
"integrity": "sha512-vOUfHxAFFvkBjbVQbBfgnCO9d3GcNfMMTtVfgqSU2rQGMFEVqWy9GBuoSfHnwGu7EqR0/GeukQcL3KjFBaga9w==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
@ -35749,9 +35749,9 @@
}
},
"node_modules/prettier": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz",
"integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==",
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"bin": {

6
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.220.0",
"version": "2.221.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -106,7 +106,7 @@
"date-fns": "4.1.0",
"dotenv": "17.2.3",
"dotenv-expand": "12.0.3",
"envalid": "8.1.0",
"envalid": "8.1.1",
"fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0",
"helmet": "7.0.0",
@ -184,7 +184,7 @@
"jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.6.0",
"nx": "21.5.1",
"prettier": "3.7.3",
"prettier": "3.7.4",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.19.0",
"react": "18.2.0",

Loading…
Cancel
Save