Browse Source

Merge remote-tracking branch 'origin/main' into feature/extend-holdings-endpoint-for-cash

pull/5650/head
KenTandrian 3 weeks ago
parent
commit
bdb0218a0f
  1. 20
      CHANGELOG.md
  2. 21
      apps/api/src/app/admin/admin.service.ts
  3. 48
      apps/api/src/app/auth/auth.controller.ts
  4. 82
      apps/api/src/app/auth/auth.module.ts
  5. 14
      apps/api/src/app/auth/auth.service.ts
  6. 19
      apps/api/src/app/auth/interfaces/interfaces.ts
  7. 114
      apps/api/src/app/auth/oidc-state.store.ts
  8. 69
      apps/api/src/app/auth/oidc.strategy.ts
  9. 4
      apps/api/src/app/info/info.service.ts
  10. 2
      apps/api/src/app/subscription/subscription.service.ts
  11. 7
      apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts
  12. 19
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  13. 6
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  14. 26
      apps/api/src/services/configuration/configuration.service.ts
  15. 2
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  16. 24
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  17. 9
      apps/api/src/services/interfaces/environment.interface.ts
  18. 7
      apps/client/src/app/components/header/header.component.ts
  19. 6
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  20. 1
      apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts
  21. 11
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html
  22. 1
      apps/client/src/app/components/user-account-membership/user-account-membership.html
  23. 102
      apps/client/src/app/pages/blog/blog-page.routes.ts
  24. 8
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  25. 6
      apps/client/src/app/pages/resources/resources-page.routes.ts
  26. 3
      libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-asset-profile-response.interface.ts
  27. 51
      libs/common/src/lib/interfaces/simplewebauthn.interface.ts
  28. 2
      libs/common/src/lib/permissions.ts
  29. 3
      libs/common/src/lib/types/subscription-offer-key.type.ts
  30. 4
      libs/common/src/lib/validators/is-currency-code.ts
  31. 6
      libs/ui/src/lib/assistant/interfaces/interfaces.ts
  32. 2
      libs/ui/src/lib/entity-logo/entity-logo.component.html
  33. 14
      libs/ui/src/lib/membership-card/membership-card.component.html
  34. 214
      libs/ui/src/lib/membership-card/membership-card.component.scss
  35. 5
      libs/ui/src/lib/membership-card/membership-card.component.stories.ts
  36. 1
      libs/ui/src/lib/membership-card/membership-card.component.ts
  37. 63
      package-lock.json
  38. 8
      package.json
  39. 2
      prisma/migrations/20251103162035_added_oidc_to_provider/migration.sql
  40. 1
      prisma/schema.prisma

20
CHANGELOG.md

@ -5,7 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased ## 2.222.0 - 2025-12-07
### Added
- Introduced data source transformation support in the import functionality for self-hosted environments
- Added _OpenID Connect_ (`OIDC`) as a new login provider for self-hosted environments (experimental)
- 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
### Added ### Added
@ -21,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Improved the country weightings in the _Financial Modeling Prep_ service - 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 ## 2.220.0 - 2025-11-29
@ -2256,7 +2272,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue in the portfolio summary with the currency conversion of fees - 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 - Removed the show condition of the experimental features setting in the user settings
## 2.95.0 - 2024-07-12 ## 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.countUsersWithAnalytics(),
this.getUsersWithAnalytics({ this.getUsersWithAnalytics({
skip, skip,
take, take
where: {
NOT: {
analytics: null
}
}
}) })
]); ]);
@ -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({ const usersWithAnalytics = await this.prismaService.user.findMany({

48
apps/api/src/app/auth/auth.controller.ts

@ -84,7 +84,6 @@ export class AuthController {
@Req() request: Request, @Req() request: Request,
@Res() response: Response @Res() response: Response
) { ) {
// Handles the Google OAuth2 callback
const jwt: string = (request.user as any).jwt; const jwt: string = (request.user as any).jwt;
if (jwt) { if (jwt) {
@ -102,6 +101,46 @@ export class AuthController {
} }
} }
@Get('oidc')
@UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL)
public oidcLogin() {
if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
@Get('oidc/callback')
@UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL)
public oidcLoginCallback(@Req() request: Request, @Res() response: Response) {
const jwt: string = (request.user as any).jwt;
if (jwt) {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
);
} else {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
}
}
@Post('webauthn/generate-authentication-options')
public async generateAuthenticationOptions(
@Body() body: { deviceId: string }
) {
return this.webAuthService.generateAuthenticationOptions(body.deviceId);
}
@Get('webauthn/generate-registration-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async generateRegistrationOptions() { public async generateRegistrationOptions() {
@ -116,13 +155,6 @@ export class AuthController {
return this.webAuthService.verifyAttestation(body.credential); return this.webAuthService.verifyAttestation(body.credential);
} }
@Post('webauthn/generate-authentication-options')
public async generateAuthenticationOptions(
@Body() body: { deviceId: string }
) {
return this.webAuthService.generateAuthenticationOptions(body.deviceId);
}
@Post('webauthn/verify-authentication') @Post('webauthn/verify-authentication')
public async verifyAuthentication( public async verifyAuthentication(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON } @Body() body: { deviceId: string; credential: AssertionCredentialJSON }

82
apps/api/src/app/auth/auth.module.ts

@ -4,17 +4,20 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Logger, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import type { StrategyOptions } from 'passport-openidconnect';
import { ApiKeyStrategy } from './api-key.strategy'; import { ApiKeyStrategy } from './api-key.strategy';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy'; import { GoogleStrategy } from './google.strategy';
import { JwtStrategy } from './jwt.strategy'; import { JwtStrategy } from './jwt.strategy';
import { OidcStrategy } from './oidc.strategy';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
@ -36,6 +39,83 @@ import { JwtStrategy } from './jwt.strategy';
AuthService, AuthService,
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
{
inject: [AuthService, ConfigurationService],
provide: OidcStrategy,
useFactory: async (
authService: AuthService,
configurationService: ConfigurationService
) => {
const isOidcEnabled = configurationService.get(
'ENABLE_FEATURE_AUTH_OIDC'
);
if (!isOidcEnabled) {
return null;
}
const issuer = configurationService.get('OIDC_ISSUER');
const scope = configurationService.get('OIDC_SCOPE');
const callbackUrl =
configurationService.get('OIDC_CALLBACK_URL') ||
`${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`;
// Check for manual URL overrides
const manualAuthorizationUrl = configurationService.get(
'OIDC_AUTHORIZATION_URL'
);
const manualTokenUrl = configurationService.get('OIDC_TOKEN_URL');
const manualUserInfoUrl =
configurationService.get('OIDC_USER_INFO_URL');
let authorizationURL: string;
let tokenURL: string;
let userInfoURL: string;
if (manualAuthorizationUrl && manualTokenUrl && manualUserInfoUrl) {
// Use manual URLs
authorizationURL = manualAuthorizationUrl;
tokenURL = manualTokenUrl;
userInfoURL = manualUserInfoUrl;
} else {
// Fetch OIDC configuration from discovery endpoint
try {
const response = await fetch(
`${issuer}/.well-known/openid-configuration`
);
const config = (await response.json()) as {
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
};
// Manual URLs take priority over discovered ones
authorizationURL =
manualAuthorizationUrl || config.authorization_endpoint;
tokenURL = manualTokenUrl || config.token_endpoint;
userInfoURL = manualUserInfoUrl || config.userinfo_endpoint;
} catch (error) {
Logger.error(error, 'OidcStrategy');
throw new Error('Failed to fetch OIDC configuration from issuer');
}
}
const options: StrategyOptions = {
authorizationURL,
issuer,
scope,
tokenURL,
userInfoURL,
callbackURL: callbackUrl,
clientID: configurationService.get('OIDC_CLIENT_ID'),
clientSecret: configurationService.get('OIDC_CLIENT_SECRET')
};
return new OidcStrategy(authService, options);
}
},
WebAuthService WebAuthService
] ]
}) })

14
apps/api/src/app/auth/auth.service.ts

@ -17,8 +17,6 @@ export class AuthService {
) {} ) {}
public async validateAnonymousLogin(accessToken: string): Promise<string> { public async validateAnonymousLogin(accessToken: string): Promise<string> {
return new Promise(async (resolve, reject) => {
try {
const hashedAccessToken = this.userService.createAccessToken({ const hashedAccessToken = this.userService.createAccessToken({
password: accessToken, password: accessToken,
salt: this.configurationService.get('ACCESS_TOKEN_SALT') salt: this.configurationService.get('ACCESS_TOKEN_SALT')
@ -29,19 +27,13 @@ export class AuthService {
}); });
if (user) { if (user) {
const jwt = this.jwtService.sign({ return this.jwtService.sign({
id: user.id id: user.id
}); });
}
resolve(jwt);
} else {
throw new Error(); throw new Error();
} }
} catch {
reject();
}
});
}
public async validateOAuthLogin({ public async validateOAuthLogin({
provider, provider,
@ -75,7 +67,7 @@ export class AuthService {
} catch (error) { } catch (error) {
throw new InternalServerErrorException( throw new InternalServerErrorException(
'validateOAuthLogin', 'validateOAuthLogin',
error.message error instanceof Error ? error.message : 'Unknown error'
); );
} }
} }

19
apps/api/src/app/auth/interfaces/interfaces.ts

@ -6,6 +6,25 @@ export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto; authDevice: AuthDeviceDto;
} }
export interface OidcContext {
claims?: {
sub?: string;
};
}
export interface OidcIdToken {
sub?: string;
}
export interface OidcParams {
sub?: string;
}
export interface OidcProfile {
id?: string;
sub?: string;
}
export interface ValidateOAuthLoginParams { export interface ValidateOAuthLoginParams {
provider: Provider; provider: Provider;
thirdPartyId: string; thirdPartyId: string;

114
apps/api/src/app/auth/oidc-state.store.ts

@ -0,0 +1,114 @@
import ms from 'ms';
/**
* Custom state store for OIDC authentication that doesn't rely on express-session.
* This store manages OAuth2 state parameters in memory with automatic cleanup.
*/
export class OidcStateStore {
private readonly STATE_EXPIRY_MS = ms('10 minutes');
private stateMap = new Map<
string,
{
appState?: unknown;
ctx: { issued?: Date; maxAge?: number; nonce?: string };
meta?: unknown;
timestamp: number;
}
>();
/**
* Store request state.
* Signature matches passport-openidconnect SessionStore
*/
public store(
_req: unknown,
_meta: unknown,
appState: unknown,
ctx: { maxAge?: number; nonce?: string; issued?: Date },
callback: (err: Error | null, handle?: string) => void
) {
try {
// Generate a unique handle for this state
const handle = this.generateHandle();
this.stateMap.set(handle, {
appState,
ctx,
meta: _meta,
timestamp: Date.now()
});
// Clean up expired states
this.cleanup();
callback(null, handle);
} catch (error) {
callback(error as Error);
}
}
/**
* Verify request state.
* Signature matches passport-openidconnect SessionStore
*/
public verify(
_req: unknown,
handle: string,
callback: (
err: Error | null,
appState?: unknown,
ctx?: { maxAge?: number; nonce?: string; issued?: Date }
) => void
) {
try {
const data = this.stateMap.get(handle);
if (!data) {
return callback(null, undefined, undefined);
}
if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) {
// State has expired
this.stateMap.delete(handle);
return callback(null, undefined, undefined);
}
// Remove state after verification (one-time use)
this.stateMap.delete(handle);
callback(null, data.ctx, data.appState);
} catch (error) {
callback(error as Error);
}
}
/**
* Clean up expired states
*/
private cleanup() {
const now = Date.now();
const expiredKeys: string[] = [];
for (const [key, value] of this.stateMap.entries()) {
if (now - value.timestamp > this.STATE_EXPIRY_MS) {
expiredKeys.push(key);
}
}
for (const key of expiredKeys) {
this.stateMap.delete(key);
}
}
/**
* Generate a cryptographically secure random handle
*/
private generateHandle() {
return (
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15) +
Date.now().toString(36)
);
}
}

69
apps/api/src/app/auth/oidc.strategy.ts

@ -0,0 +1,69 @@
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { Request } from 'express';
import { Strategy, type StrategyOptions } from 'passport-openidconnect';
import { AuthService } from './auth.service';
import {
OidcContext,
OidcIdToken,
OidcParams,
OidcProfile
} from './interfaces/interfaces';
import { OidcStateStore } from './oidc-state.store';
@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private static readonly stateStore = new OidcStateStore();
public constructor(
private readonly authService: AuthService,
options: StrategyOptions
) {
super({
...options,
passReqToCallback: true,
store: OidcStrategy.stateStore
});
}
public async validate(
_request: Request,
issuer: string,
profile: OidcProfile,
context: OidcContext,
idToken: OidcIdToken,
_accessToken: string,
_refreshToken: string,
params: OidcParams
) {
try {
const thirdPartyId =
profile?.id ??
profile?.sub ??
idToken?.sub ??
params?.sub ??
context?.claims?.sub;
const jwt = await this.authService.validateOAuthLogin({
thirdPartyId,
provider: Provider.OIDC
});
if (!thirdPartyId) {
Logger.error(
`Missing subject identifier in OIDC response from ${issuer}`,
'OidcStrategy'
);
throw new Error('Missing subject identifier in OIDC response');
}
return { jwt };
} catch (error) {
Logger.error(error, 'OidcStrategy');
throw error;
}
}
}

4
apps/api/src/app/info/info.service.ts

@ -55,6 +55,10 @@ export class InfoService {
globalPermissions.push(permissions.enableAuthGoogle); globalPermissions.push(permissions.enableAuthGoogle);
} }
if (this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
globalPermissions.push(permissions.enableAuthOidc);
}
if (this.configurationService.get('ENABLE_FEATURE_AUTH_TOKEN')) { if (this.configurationService.get('ENABLE_FEATURE_AUTH_TOKEN')) {
globalPermissions.push(permissions.enableAuthToken); globalPermissions.push(permissions.enableAuthToken);
} }

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

@ -179,6 +179,8 @@ export class SubscriptionService {
offerKey = 'renewal-early-bird-2023'; offerKey = 'renewal-early-bird-2023';
} else if (isBefore(createdAt, parseDate('2024-01-01'))) { } else if (isBefore(createdAt, parseDate('2024-01-01'))) {
offerKey = 'renewal-early-bird-2024'; offerKey = 'renewal-early-bird-2024';
} else if (isBefore(createdAt, parseDate('2025-12-01'))) {
offerKey = 'renewal-early-bird-2025';
} }
const offer = await this.getSubscriptionOffer({ 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'; import { map } from 'rxjs/operators';
@Injectable() @Injectable()
export class RedactValuesInResponseInterceptor<T> export class RedactValuesInResponseInterceptor<T> implements NestInterceptor<
implements NestInterceptor<T, any> T,
{ any
> {
public intercept( public intercept(
context: ExecutionContext, context: ExecutionContext,
next: CallHandler<T> 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'; import { Observable } from 'rxjs';
@Injectable() @Injectable()
export class TransformDataSourceInRequestInterceptor<T> export class TransformDataSourceInRequestInterceptor<
implements NestInterceptor<T, any> T
{ > implements NestInterceptor<T, any> {
public constructor( public constructor(
private readonly configurationService: ConfigurationService 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(); 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'; import { map } from 'rxjs/operators';
@Injectable() @Injectable()
export class TransformDataSourceInResponseInterceptor<T> export class TransformDataSourceInResponseInterceptor<
implements NestInterceptor<T, any> T
{ > implements NestInterceptor<T, any> {
private encodedDataSourceMap: { private encodedDataSourceMap: {
[dataSource: string]: string; [dataSource: string]: string;
} = {}; } = {};

26
apps/api/src/services/configuration/configuration.service.ts

@ -41,6 +41,7 @@ export class ConfigurationService {
default: [] default: []
}), }),
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
@ -54,9 +55,32 @@ export class ConfigurationService {
GOOGLE_SHEETS_ID: str({ default: '' }), GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }), GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
HOST: host({ default: DEFAULT_HOST }), HOST: host({ default: DEFAULT_HOST }),
JWT_SECRET_KEY: str({}), JWT_SECRET_KEY: str(),
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_CHART_ITEMS: num({ default: 365 }), MAX_CHART_ITEMS: num({ default: 365 }),
OIDC_AUTHORIZATION_URL: str({ default: '' }),
OIDC_CALLBACK_URL: str({ default: '' }),
OIDC_CLIENT_ID: str({
default: undefined,
requiredWhen: (env) => {
return env.ENABLE_FEATURE_AUTH_OIDC === true;
}
}),
OIDC_CLIENT_SECRET: str({
default: undefined,
requiredWhen: (env) => {
return env.ENABLE_FEATURE_AUTH_OIDC === true;
}
}),
OIDC_ISSUER: str({
default: undefined,
requiredWhen: (env) => {
return env.ENABLE_FEATURE_AUTH_OIDC === true;
}
}),
OIDC_SCOPE: json({ default: ['openid'] }),
OIDC_TOKEN_URL: str({ default: '' }),
OIDC_USER_INFO_URL: str({ default: '' }),
PORT: port({ default: DEFAULT_PORT }), PORT: port({ default: DEFAULT_PORT }),
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({
default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY

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 }; return { assetClass, assetSubClass };
} }
private parseSector(aString: string): string { private parseSector(aString: string) {
let sector = UNKNOWN_KEY; let sector = UNKNOWN_KEY;
switch (aString) { switch (aString) {

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

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

9
apps/api/src/services/interfaces/environment.interface.ts

@ -17,6 +17,7 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCES: string[]; DATA_SOURCES: string[];
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
@ -32,6 +33,14 @@ export interface Environment extends CleanedEnvAccessors {
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ACTIVITIES_TO_IMPORT: number; MAX_ACTIVITIES_TO_IMPORT: number;
MAX_CHART_ITEMS: number; MAX_CHART_ITEMS: number;
OIDC_AUTHORIZATION_URL: string;
OIDC_CALLBACK_URL: string;
OIDC_CLIENT_ID: string;
OIDC_CLIENT_SECRET: string;
OIDC_ISSUER: string;
OIDC_SCOPE: string[];
OIDC_TOKEN_URL: string;
OIDC_USER_INFO_URL: string;
PORT: number; PORT: number;
PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number; PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number;
PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number; PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number;

7
apps/client/src/app/components/header/header.component.ts

@ -105,6 +105,7 @@ export class GfHeaderComponent implements OnChanges {
public hasFilters: boolean; public hasFilters: boolean;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionForAuthGoogle: boolean; public hasPermissionForAuthGoogle: boolean;
public hasPermissionForAuthOidc: boolean;
public hasPermissionForAuthToken: boolean; public hasPermissionForAuthToken: boolean;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean; public hasPermissionToAccessAdminControl: boolean;
@ -170,6 +171,11 @@ export class GfHeaderComponent implements OnChanges {
permissions.enableAuthGoogle permissions.enableAuthGoogle
); );
this.hasPermissionForAuthOidc = hasPermission(
this.info?.globalPermissions,
permissions.enableAuthOidc
);
this.hasPermissionForAuthToken = hasPermission( this.hasPermissionForAuthToken = hasPermission(
this.info?.globalPermissions, this.info?.globalPermissions,
permissions.enableAuthToken permissions.enableAuthToken
@ -286,6 +292,7 @@ export class GfHeaderComponent implements OnChanges {
data: { data: {
accessToken: '', accessToken: '',
hasPermissionToUseAuthGoogle: this.hasPermissionForAuthGoogle, hasPermissionToUseAuthGoogle: this.hasPermissionForAuthGoogle,
hasPermissionToUseAuthOidc: this.hasPermissionForAuthOidc,
hasPermissionToUseAuthToken: this.hasPermissionForAuthToken, hasPermissionToUseAuthToken: this.hasPermissionForAuthToken,
title: $localize`Sign in` title: $localize`Sign in`
}, },

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)) { if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0; this.quantityPrecision = 0;
} else if (SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') { } else if (SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 1) { if (this.quantity < 10) {
this.quantityPrecision = 7; this.quantityPrecision = 8;
} else if (this.quantity < 1000) { } else if (this.quantity < 1000) {
this.quantityPrecision = 5; this.quantityPrecision = 6;
} else if (this.quantity >= 10000000) { } else if (this.quantity >= 10000000) {
this.quantityPrecision = 0; this.quantityPrecision = 0;
} }

1
apps/client/src/app/components/login-with-access-token-dialog/interfaces/interfaces.ts

@ -1,6 +1,7 @@
export interface LoginWithAccessTokenDialogParams { export interface LoginWithAccessTokenDialogParams {
accessToken: string; accessToken: string;
hasPermissionToUseAuthGoogle: boolean; hasPermissionToUseAuthGoogle: boolean;
hasPermissionToUseAuthOidc: boolean;
hasPermissionToUseAuthToken: boolean; hasPermissionToUseAuthToken: boolean;
title: string; title: string;
} }

11
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html

@ -45,6 +45,17 @@
> >
</div> </div>
} }
@if (data.hasPermissionToUseAuthOidc) {
<div class="d-flex flex-column mt-2">
<a
class="px-4 rounded-pill"
href="../api/auth/oidc"
mat-stroked-button
><ng-container i18n>Sign in with OpenID Connect</ng-container></a
>
</div>
}
</form> </form>
</div> </div>
</div> </div>

1
apps/client/src/app/components/user-account-membership/user-account-membership.html

@ -5,6 +5,7 @@
<gf-membership-card <gf-membership-card
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat" [expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
[hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey" [hasPermissionToCreateApiKey]="hasPermissionToCreateApiKey"
[hover3d]="true"
[name]="user?.subscription?.type" [name]="user?.subscription?.type"
(generateApiKeyClicked)="onGenerateApiKey()" (generateApiKeyClicked)="onGenerateApiKey()"
/> />

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

@ -34,117 +34,117 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2022/01/ghostfolio-first-months-in-open-source', path: '2022/01/ghostfolio-first-months-in-open-source',
loadComponent: () => loadComponent: () =>
import( import('./2022/01/first-months-in-open-source/first-months-in-open-source-page.component').then(
'./2022/01/first-months-in-open-source/first-months-in-open-source-page.component' (c) => c.FirstMonthsInOpenSourcePageComponent
).then((c) => c.FirstMonthsInOpenSourcePageComponent), ),
title: 'First months in Open Source' title: 'First months in Open Source'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2022/07/ghostfolio-meets-internet-identity', path: '2022/07/ghostfolio-meets-internet-identity',
loadComponent: () => loadComponent: () =>
import( import('./2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component').then(
'./2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.component' (c) => c.GhostfolioMeetsInternetIdentityPageComponent
).then((c) => c.GhostfolioMeetsInternetIdentityPageComponent), ),
title: 'Ghostfolio meets Internet Identity' title: 'Ghostfolio meets Internet Identity'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2022/07/how-do-i-get-my-finances-in-order', path: '2022/07/how-do-i-get-my-finances-in-order',
loadComponent: () => loadComponent: () =>
import( import('./2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component').then(
'./2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.component' (c) => c.HowDoIGetMyFinancesInOrderPageComponent
).then((c) => c.HowDoIGetMyFinancesInOrderPageComponent), ),
title: 'How do I get my finances in order?' title: 'How do I get my finances in order?'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2022/08/500-stars-on-github', path: '2022/08/500-stars-on-github',
loadComponent: () => loadComponent: () =>
import( import('./2022/08/500-stars-on-github/500-stars-on-github-page.component').then(
'./2022/08/500-stars-on-github/500-stars-on-github-page.component' (c) => c.FiveHundredStarsOnGitHubPageComponent
).then((c) => c.FiveHundredStarsOnGitHubPageComponent), ),
title: '500 Stars on GitHub' title: '500 Stars on GitHub'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2022/10/hacktoberfest-2022', path: '2022/10/hacktoberfest-2022',
loadComponent: () => loadComponent: () =>
import( import('./2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component').then(
'./2022/10/hacktoberfest-2022/hacktoberfest-2022-page.component' (c) => c.Hacktoberfest2022PageComponent
).then((c) => c.Hacktoberfest2022PageComponent), ),
title: 'Hacktoberfest 2022' title: 'Hacktoberfest 2022'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2022/11/black-friday-2022', path: '2022/11/black-friday-2022',
loadComponent: () => loadComponent: () =>
import( import('./2022/11/black-friday-2022/black-friday-2022-page.component').then(
'./2022/11/black-friday-2022/black-friday-2022-page.component' (c) => c.BlackFriday2022PageComponent
).then((c) => c.BlackFriday2022PageComponent), ),
title: 'Black Friday 2022' title: 'Black Friday 2022'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2022/12/the-importance-of-tracking-your-personal-finances', path: '2022/12/the-importance-of-tracking-your-personal-finances',
loadComponent: () => loadComponent: () =>
import( import('./2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.component').then(
'./2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.component' (c) => c.TheImportanceOfTrackingYourPersonalFinancesPageComponent
).then((c) => c.TheImportanceOfTrackingYourPersonalFinancesPageComponent), ),
title: 'The importance of tracking your personal finances' title: 'The importance of tracking your personal finances'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2023/01/ghostfolio-auf-sackgeld-vorgestellt', path: '2023/01/ghostfolio-auf-sackgeld-vorgestellt',
loadComponent: () => loadComponent: () =>
import( import('./2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.component').then(
'./2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.component' (c) => c.GhostfolioAufSackgeldVorgestelltPageComponent
).then((c) => c.GhostfolioAufSackgeldVorgestelltPageComponent), ),
title: 'Ghostfolio auf Sackgeld.com vorgestellt' title: 'Ghostfolio auf Sackgeld.com vorgestellt'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2023/02/ghostfolio-meets-umbrel', path: '2023/02/ghostfolio-meets-umbrel',
loadComponent: () => loadComponent: () =>
import( import('./2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.component').then(
'./2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.component' (c) => c.GhostfolioMeetsUmbrelPageComponent
).then((c) => c.GhostfolioMeetsUmbrelPageComponent), ),
title: 'Ghostfolio meets Umbrel' title: 'Ghostfolio meets Umbrel'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2023/03/ghostfolio-reaches-1000-stars-on-github', path: '2023/03/ghostfolio-reaches-1000-stars-on-github',
loadComponent: () => loadComponent: () =>
import( import('./2023/03/1000-stars-on-github/1000-stars-on-github-page.component').then(
'./2023/03/1000-stars-on-github/1000-stars-on-github-page.component' (c) => c.ThousandStarsOnGitHubPageComponent
).then((c) => c.ThousandStarsOnGitHubPageComponent), ),
title: 'Ghostfolio reaches 1’000 Stars on GitHub' title: 'Ghostfolio reaches 1’000 Stars on GitHub'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2023/05/unlock-your-financial-potential-with-ghostfolio', path: '2023/05/unlock-your-financial-potential-with-ghostfolio',
loadComponent: () => loadComponent: () =>
import( import('./2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.component').then(
'./2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.component' (c) => c.UnlockYourFinancialPotentialWithGhostfolioPageComponent
).then((c) => c.UnlockYourFinancialPotentialWithGhostfolioPageComponent), ),
title: 'Unlock your Financial Potential with Ghostfolio' title: 'Unlock your Financial Potential with Ghostfolio'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2023/07/exploring-the-path-to-fire', path: '2023/07/exploring-the-path-to-fire',
loadComponent: () => loadComponent: () =>
import( import('./2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.component').then(
'./2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.component' (c) => c.ExploringThePathToFirePageComponent
).then((c) => c.ExploringThePathToFirePageComponent), ),
title: 'Exploring the Path to FIRE' title: 'Exploring the Path to FIRE'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2023/08/ghostfolio-joins-oss-friends', path: '2023/08/ghostfolio-joins-oss-friends',
loadComponent: () => loadComponent: () =>
import( import('./2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component').then(
'./2023/08/ghostfolio-joins-oss-friends/ghostfolio-joins-oss-friends-page.component' (c) => c.GhostfolioJoinsOssFriendsPageComponent
).then((c) => c.GhostfolioJoinsOssFriendsPageComponent), ),
title: 'Ghostfolio joins OSS Friends' title: 'Ghostfolio joins OSS Friends'
}, },
{ {
@ -160,9 +160,9 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2023/09/hacktoberfest-2023', path: '2023/09/hacktoberfest-2023',
loadComponent: () => loadComponent: () =>
import( import('./2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component').then(
'./2023/09/hacktoberfest-2023/hacktoberfest-2023-page.component' (c) => c.Hacktoberfest2023PageComponent
).then((c) => c.Hacktoberfest2023PageComponent), ),
title: 'Hacktoberfest 2023' title: 'Hacktoberfest 2023'
}, },
{ {
@ -178,18 +178,18 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2023/11/hacktoberfest-2023-debriefing', path: '2023/11/hacktoberfest-2023-debriefing',
loadComponent: () => loadComponent: () =>
import( import('./2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component').then(
'./2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component' (c) => c.Hacktoberfest2023DebriefingPageComponent
).then((c) => c.Hacktoberfest2023DebriefingPageComponent), ),
title: 'Hacktoberfest 2023 Debriefing' title: 'Hacktoberfest 2023 Debriefing'
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2024/09/hacktoberfest-2024', path: '2024/09/hacktoberfest-2024',
loadComponent: () => loadComponent: () =>
import( import('./2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component').then(
'./2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component' (c) => c.Hacktoberfest2024PageComponent
).then((c) => c.Hacktoberfest2024PageComponent), ),
title: 'Hacktoberfest 2024' title: 'Hacktoberfest 2024'
}, },
{ {
@ -205,9 +205,9 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
path: '2025/09/hacktoberfest-2025', path: '2025/09/hacktoberfest-2025',
loadComponent: () => loadComponent: () =>
import( import('./2025/09/hacktoberfest-2025/hacktoberfest-2025-page.component').then(
'./2025/09/hacktoberfest-2025/hacktoberfest-2025-page.component' (c) => c.Hacktoberfest2025PageComponent
).then((c) => c.Hacktoberfest2025PageComponent), ),
title: 'Hacktoberfest 2025' 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 durationExtension: StringValue;
public hasPermissionToCreateUser: boolean; public hasPermissionToCreateUser: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public importAndExportTooltipBasic = translate( public importAndExportTooltipBasic = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC' 'DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC'
); );
public importAndExportTooltipOSS = translate( public importAndExportTooltipOSS = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS' 'DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS'
); );
public importAndExportTooltipPremium = translate( public importAndExportTooltipPremium = translate(
'DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM' 'DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM'
); );
public isLoggedIn: boolean; public isLoggedIn: boolean;
public label: string; public label: string;
public price: number; public price: number;
public priceId: string; public priceId: string;
public professionalDataProviderTooltipPremium = translate( public professionalDataProviderTooltipPremium = translate(
'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM' 'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM'
); );
public referralBrokers = [ public referralBrokers = [
'Alpian',
'DEGIRO', 'DEGIRO',
'finpension', 'finpension',
'frankly', 'frankly',
@ -80,6 +87,7 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
'VIAC', 'VIAC',
'Zak' 'Zak'
]; ];
public routerLinkFeatures = publicRoutes.features.routerLink; public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
public user: User; 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, path: publicRoutes.resources.subRoutes.personalFinanceTools.path,
loadChildren: () => loadChildren: () =>
import( import('./personal-finance-tools/personal-finance-tools-page.routes').then(
'./personal-finance-tools/personal-finance-tools-page.routes' (m) => m.routes
).then((m) => m.routes) )
} }
], ],
path: '', 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'; import { SymbolProfile } from '@prisma/client';
export interface DataProviderGhostfolioAssetProfileResponse export interface DataProviderGhostfolioAssetProfileResponse extends Partial<SymbolProfile> {}
extends Partial<SymbolProfile> {}

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

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

2
libs/common/src/lib/permissions.ts

@ -29,6 +29,7 @@ export const permissions = {
deleteUser: 'deleteUser', deleteUser: 'deleteUser',
deleteWatchlistItem: 'deleteWatchlistItem', deleteWatchlistItem: 'deleteWatchlistItem',
enableAuthGoogle: 'enableAuthGoogle', enableAuthGoogle: 'enableAuthGoogle',
enableAuthOidc: 'enableAuthOidc',
enableAuthToken: 'enableAuthToken', enableAuthToken: 'enableAuthToken',
enableDataProviderGhostfolio: 'enableDataProviderGhostfolio', enableDataProviderGhostfolio: 'enableDataProviderGhostfolio',
enableFearAndGreedIndex: 'enableFearAndGreedIndex', enableFearAndGreedIndex: 'enableFearAndGreedIndex',
@ -159,6 +160,7 @@ export function filterGlobalPermissions(
return globalPermissions.filter((permission) => { return globalPermissions.filter((permission) => {
return ( return (
permission !== permissions.enableAuthGoogle && permission !== permissions.enableAuthGoogle &&
permission !== permissions.enableAuthOidc &&
permission !== permissions.enableSubscription permission !== permissions.enableSubscription
); );
}); });

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

@ -2,4 +2,5 @@ export type SubscriptionOfferKey =
| 'default' | 'default'
| 'renewal' | 'renewal'
| 'renewal-early-bird-2023' | '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 }) @ValidatorConstraint({ async: false })
export class IsExtendedCurrencyConstraint export class IsExtendedCurrencyConstraint implements ValidatorConstraintInterface {
implements ValidatorConstraintInterface
{
public defaultMessage() { public defaultMessage() {
return '$property must be a valid ISO4217 currency code'; 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'; import { SearchMode } from '../enums/search-mode';
export interface AccountSearchResultItem export interface AccountSearchResultItem extends Pick<
extends Pick<AccountWithValue, 'id' | 'name'> { AccountWithValue,
'id' | 'name'
> {
mode: SearchMode.ACCOUNT; mode: SearchMode.ACCOUNT;
routerLink: string[]; routerLink: string[];
} }

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

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

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

@ -1,11 +1,14 @@
<div <div class="card-wrapper position-relative" [class.hover-3d]="hover3d">
class="card-container position-relative" <div class="card-container" [ngClass]="{ premium: name === 'Premium' }">
[ngClass]="{ premium: name === 'Premium' }"
>
<a <a
class="card-item d-flex flex-column justify-content-between p-4" class="card-item d-flex flex-column justify-content-between p-4"
[routerLink]="routerLinkPricing" [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"> <div class="d-flex justify-content-end">
<gf-logo <gf-logo
size="large" size="large"
@ -20,7 +23,7 @@
<div class="text-monospace value">* * * * * * * * *</div> <div class="text-monospace value">* * * * * * * * *</div>
<div class="ml-1"> <div class="ml-1">
<button <button
class="no-min-width" class="bg-transparent no-min-width"
i18n-title i18n-title
mat-button mat-button
title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..." title="Generate Ghostfolio Premium Data Provider API key for self-hosted environments..."
@ -47,4 +50,5 @@
} }
</div> </div>
</a> </a>
</div>
</div> </div>

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

@ -1,42 +1,22 @@
:host { :host {
--borderRadius: 1rem; --borderRadius: 1rem;
--borderWidth: 2px; --borderWidth: 2px;
--hover3dSpotlightOpacity: 0.2;
display: block; display: block;
max-width: 25rem; max-width: 25rem;
padding-top: calc(1 * var(--borderWidth)); padding-top: calc(1 * var(--borderWidth));
width: 100%; width: 100%;
.card-wrapper {
&.hover-3d {
perspective: 1000px;
}
.card-container { .card-container {
border-radius: var(--borderRadius); border-radius: var(--borderRadius);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
&:after {
animation: animatedborder 7s ease alternate infinite;
background: linear-gradient(60deg, #5073b8, #1098ad, #07b39b, #6fba82);
background-size: 300% 300%;
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%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
}
.card-item { .card-item {
aspect-ratio: 1.586; aspect-ratio: 1.586;
background-color: #1d2124; background-color: #1d2124;
@ -47,6 +27,7 @@
button { button {
color: rgba(var(--light-primary-text)); color: rgba(var(--light-primary-text));
height: 1.5rem; height: 1.5rem;
z-index: 3;
} }
.heading { .heading {
@ -59,7 +40,7 @@
} }
&:not(.premium) { &:not(.premium) {
&:after { &::after {
opacity: 0; opacity: 0;
} }
@ -69,4 +50,183 @@
} }
} }
} }
&.hover-3d {
--hover3d-rotate-x: 0;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 100%;
.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;
}
}
}
}
&:has(.hover-zone:hover) .card-container {
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3);
scale: 1.05;
&::before {
opacity: 1;
}
}
&:has(.hover-zone:nth-child(1):hover) {
--hover3d-rotate-x: 1;
--hover3d-rotate-y: -1;
--hover3d-shine: 0% 0%;
}
&:has(.hover-zone:nth-child(2):hover) {
--hover3d-rotate-x: 1;
--hover3d-rotate-y: 0;
--hover3d-shine: 100% 0%;
}
&: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: { argTypes: {
hover3d: {
control: { type: 'boolean' }
},
name: { name: {
control: { type: 'select' }, control: { type: 'select' },
options: ['Basic', 'Premium'] options: ['Basic', 'Premium']
@ -37,6 +40,7 @@ type Story = StoryObj<GfMembershipCardComponent>;
export const Basic: Story = { export const Basic: Story = {
args: { args: {
hover3d: false,
name: 'Basic' name: 'Basic'
} }
}; };
@ -45,6 +49,7 @@ export const Premium: Story = {
args: { args: {
expiresAt: addYears(new Date(), 1).toLocaleDateString(), expiresAt: addYears(new Date(), 1).toLocaleDateString(),
hasPermissionToCreateApiKey: true, hasPermissionToCreateApiKey: true,
hover3d: false,
name: 'Premium' name: 'Premium'
} }
}; };

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

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

63
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.220.0", "version": "2.222.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.220.0", "version": "2.222.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -62,7 +62,7 @@
"date-fns": "4.1.0", "date-fns": "4.1.0",
"dotenv": "17.2.3", "dotenv": "17.2.3",
"dotenv-expand": "12.0.3", "dotenv-expand": "12.0.3",
"envalid": "8.1.0", "envalid": "8.1.1",
"fuse.js": "7.1.0", "fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0", "google-spreadsheet": "3.2.0",
"helmet": "7.0.0", "helmet": "7.0.0",
@ -83,6 +83,7 @@
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-headerapikey": "1.2.2", "passport-headerapikey": "1.2.2",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"passport-openidconnect": "0.1.2",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"stripe": "18.5.0", "stripe": "18.5.0",
@ -129,6 +130,7 @@
"@types/node": "22.15.17", "@types/node": "22.15.17",
"@types/papaparse": "5.3.7", "@types/papaparse": "5.3.7",
"@types/passport-google-oauth20": "2.0.16", "@types/passport-google-oauth20": "2.0.16",
"@types/passport-openidconnect": "0.1.3",
"@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/eslint-plugin": "8.43.0",
"@typescript-eslint/parser": "8.43.0", "@typescript-eslint/parser": "8.43.0",
"eslint": "9.35.0", "eslint": "9.35.0",
@ -140,7 +142,7 @@
"jest-environment-jsdom": "29.7.0", "jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.6.0", "jest-preset-angular": "14.6.0",
"nx": "21.5.1", "nx": "21.5.1",
"prettier": "3.7.3", "prettier": "3.7.4",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.19.0", "prisma": "6.19.0",
"react": "18.2.0", "react": "18.2.0",
@ -14502,6 +14504,30 @@
"@types/passport": "*" "@types/passport": "*"
} }
}, },
"node_modules/@types/passport-openidconnect": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@types/passport-openidconnect/-/passport-openidconnect-0.1.3.tgz",
"integrity": "sha512-k1Ni7bG/9OZNo2Qpjg2W6GajL+pww6ZPaNWMXfpteCX4dXf4QgaZLt2hjR5IiPrqwBT9+W8KjCTJ/uhGIoBx/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/oauth": "*",
"@types/passport": "*",
"@types/passport-strategy": "*"
}
},
"node_modules/@types/passport-strategy": {
"version": "0.2.38",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz",
"integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/passport": "*"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.14.0", "version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@ -21332,9 +21358,9 @@
} }
}, },
"node_modules/envalid": { "node_modules/envalid": {
"version": "8.1.0", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/envalid/-/envalid-8.1.0.tgz", "resolved": "https://registry.npmjs.org/envalid/-/envalid-8.1.1.tgz",
"integrity": "sha512-OT6+qVhKVyCidaGoXflb2iK1tC8pd0OV2Q+v9n33wNhUJ+lus+rJobUj4vJaQBPxPZ0vYrPGuxdrenyCAIJcow==", "integrity": "sha512-vOUfHxAFFvkBjbVQbBfgnCO9d3GcNfMMTtVfgqSU2rQGMFEVqWy9GBuoSfHnwGu7EqR0/GeukQcL3KjFBaga9w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "2.8.1" "tslib": "2.8.1"
@ -34723,6 +34749,23 @@
"url": "https://github.com/sponsors/jaredhanson" "url": "https://github.com/sponsors/jaredhanson"
} }
}, },
"node_modules/passport-openidconnect": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.2.tgz",
"integrity": "sha512-JX3rTyW+KFZ/E9OF/IpXJPbyLO9vGzcmXB5FgSP2jfL3LGKJPdV7zUE8rWeKeeI/iueQggOeFa3onrCmhxXZTg==",
"license": "MIT",
"dependencies": {
"oauth": "0.10.x",
"passport-strategy": "1.x.x"
},
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": { "node_modules/passport-strategy": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
@ -35749,9 +35792,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.7.3", "version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {

8
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.220.0", "version": "2.222.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -106,7 +106,7 @@
"date-fns": "4.1.0", "date-fns": "4.1.0",
"dotenv": "17.2.3", "dotenv": "17.2.3",
"dotenv-expand": "12.0.3", "dotenv-expand": "12.0.3",
"envalid": "8.1.0", "envalid": "8.1.1",
"fuse.js": "7.1.0", "fuse.js": "7.1.0",
"google-spreadsheet": "3.2.0", "google-spreadsheet": "3.2.0",
"helmet": "7.0.0", "helmet": "7.0.0",
@ -127,6 +127,7 @@
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-headerapikey": "1.2.2", "passport-headerapikey": "1.2.2",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"passport-openidconnect": "0.1.2",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.1", "rxjs": "7.8.1",
"stripe": "18.5.0", "stripe": "18.5.0",
@ -173,6 +174,7 @@
"@types/node": "22.15.17", "@types/node": "22.15.17",
"@types/papaparse": "5.3.7", "@types/papaparse": "5.3.7",
"@types/passport-google-oauth20": "2.0.16", "@types/passport-google-oauth20": "2.0.16",
"@types/passport-openidconnect": "0.1.3",
"@typescript-eslint/eslint-plugin": "8.43.0", "@typescript-eslint/eslint-plugin": "8.43.0",
"@typescript-eslint/parser": "8.43.0", "@typescript-eslint/parser": "8.43.0",
"eslint": "9.35.0", "eslint": "9.35.0",
@ -184,7 +186,7 @@
"jest-environment-jsdom": "29.7.0", "jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.6.0", "jest-preset-angular": "14.6.0",
"nx": "21.5.1", "nx": "21.5.1",
"prettier": "3.7.3", "prettier": "3.7.4",
"prettier-plugin-organize-attributes": "1.0.0", "prettier-plugin-organize-attributes": "1.0.0",
"prisma": "6.19.0", "prisma": "6.19.0",
"react": "18.2.0", "react": "18.2.0",

2
prisma/migrations/20251103162035_added_oidc_to_provider/migration.sql

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Provider" ADD VALUE 'OIDC';

1
prisma/schema.prisma

@ -335,6 +335,7 @@ enum Provider {
ANONYMOUS ANONYMOUS
GOOGLE GOOGLE
INTERNET_IDENTITY INTERNET_IDENTITY
OIDC
} }
enum Role { enum Role {

Loading…
Cancel
Save