Browse Source

Feature/migrate token user to oidc

pull/6075/head
Germán Martín 2 weeks ago
parent
commit
970566aca1
  1. 90
      apps/api/src/app/auth/auth.controller.ts
  2. 5
      apps/api/src/app/auth/auth.module.ts
  3. 144
      apps/api/src/app/auth/auth.service.ts
  4. 138
      apps/api/src/app/auth/oidc-state.store.ts
  5. 64
      apps/api/src/app/auth/oidc.strategy.ts
  6. 12
      apps/api/src/app/user/user.service.ts
  7. 92
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  8. 23
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  9. 4
      libs/common/src/lib/interfaces/user.interface.ts

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

@ -13,8 +13,10 @@ import {
Controller,
Get,
HttpException,
Logger,
Param,
Post,
Query,
Req,
Res,
UseGuards,
@ -26,6 +28,7 @@ import { Request, Response } from 'express';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { AuthService } from './auth.service';
import { OidcValidationResult } from './oidc.strategy';
@Controller('auth')
export class AuthController {
@ -104,33 +107,94 @@ export class AuthController {
@Get('oidc')
@UseGuards(AuthGuard('oidc'))
@Version(VERSION_NEUTRAL)
public oidcLogin() {
public oidcLogin(@Query('linkMode') linkMode: string) {
if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
// Link mode is handled automatically by OidcStateStore.store()
// which extracts the token from query params and validates it
if (linkMode === 'true') {
Logger.log(
'OIDC link mode requested - token validation handled by OidcStateStore',
'AuthController'
);
} else {
Logger.debug('OIDC normal login flow initiated', 'AuthController');
}
// The AuthGuard('oidc') handles the redirect to the OIDC provider
}
@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;
public async oidcLoginCallback(
@Req() request: Request,
@Res() response: Response
) {
const result = request.user as OidcValidationResult;
const rootUrl = this.configurationService.get('ROOT_URL');
if (jwt) {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`
// Check if this is a link mode callback
if (result.linkState?.linkMode) {
Logger.log(
`OIDC callback: Link mode detected for user ${result.linkState.userId.substring(0, 8)}...`,
'AuthController'
);
try {
// Link the OIDC account to the existing user
await this.authService.linkOidcToUser(
result.linkState.userId,
result.thirdPartyId
);
Logger.log(
`OIDC callback: Successfully linked OIDC to user ${result.linkState.userId.substring(0, 8)}...`,
'AuthController'
);
// Redirect to account page with success message
response.redirect(
`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkSuccess=true`
);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
Logger.error(
`OIDC callback: Link failed - ${errorMessage}`,
'AuthController'
);
// Determine error type for frontend
let errorCode = 'unknown';
if (errorMessage.includes('already linked')) {
errorCode = 'already-linked';
} else if (errorMessage.includes('not found')) {
errorCode = 'invalid-session';
} else if (errorMessage.includes('token authentication')) {
errorCode = 'invalid-provider';
}
response.redirect(
`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/account?linkError=${errorCode}`
);
}
return;
}
// Normal OIDC login flow
Logger.debug('OIDC callback: Normal login flow', 'AuthController');
const jwt: string = result.jwt;
if (jwt) {
response.redirect(`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}`);
} else {
response.redirect(
`${this.configurationService.get(
'ROOT_URL'
)}/${DEFAULT_LANGUAGE_CODE}/auth`
);
response.redirect(`${rootUrl}/${DEFAULT_LANGUAGE_CODE}/auth`);
}
}

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

@ -113,7 +113,10 @@ import { OidcStrategy } from './oidc.strategy';
clientSecret: configurationService.get('OIDC_CLIENT_SECRET')
};
return new OidcStrategy(authService, options);
// Pass JWT secret for link mode validation
const jwtSecret = configurationService.get('JWT_SECRET_KEY');
return new OidcStrategy(authService, { ...options, jwtSecret });
}
},
WebAuthService

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

@ -2,7 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import {
ConflictException,
Injectable,
InternalServerErrorException,
Logger
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@ -40,35 +45,144 @@ export class AuthService {
thirdPartyId
}: ValidateOAuthLoginParams): Promise<string> {
try {
Logger.debug(
`validateOAuthLogin: Validating login for provider ${provider}, thirdPartyId ${thirdPartyId?.substring(0, 8)}...`,
'AuthService'
);
// First, search by thirdPartyId only to support linked accounts
// (users with provider ANONYMOUS but with thirdPartyId set)
let [user] = await this.userService.users({
where: { provider, thirdPartyId }
where: { thirdPartyId }
});
if (!user) {
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (user) {
Logger.log(
`validateOAuthLogin: Found existing user ${user.id.substring(0, 8)}... with provider ${user.provider} for thirdPartyId`,
'AuthService'
);
return this.jwtService.sign({
id: user.id
});
}
if (!isUserSignupEnabled) {
throw new Error('Sign up forbidden');
}
Logger.debug(
`validateOAuthLogin: No user found with thirdPartyId, checking if signup is enabled`,
'AuthService'
);
// Create new user if not found
user = await this.userService.createUser({
data: {
provider,
thirdPartyId
}
});
const isUserSignupEnabled =
await this.propertyService.isUserSignupEnabled();
if (!isUserSignupEnabled) {
Logger.warn(
`validateOAuthLogin: Sign up is disabled, rejecting new user`,
'AuthService'
);
throw new Error('Sign up forbidden');
}
// Create new user if not found
Logger.log(
`validateOAuthLogin: Creating new user with provider ${provider}`,
'AuthService'
);
user = await this.userService.createUser({
data: {
provider,
thirdPartyId
}
});
return this.jwtService.sign({
id: user.id
});
} catch (error) {
Logger.error(
`validateOAuthLogin: Error - ${error instanceof Error ? error.message : 'Unknown error'}`,
'AuthService'
);
throw new InternalServerErrorException(
'validateOAuthLogin',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
/**
* Links an OIDC provider to an existing user account.
* The user must have provider ANONYMOUS (token-based auth).
* The thirdPartyId must not be already linked to another user.
*
* @param userId - The ID of the user to link
* @param thirdPartyId - The OIDC subject identifier
* @returns JWT token for the linked user
* @throws ConflictException if thirdPartyId is already linked to another user
* @throws Error if user not found or has invalid provider
*/
public async linkOidcToUser(
userId: string,
thirdPartyId: string
): Promise<string> {
Logger.log(
`linkOidcToUser: Starting link process for user ${userId.substring(0, 8)}... with thirdPartyId ${thirdPartyId.substring(0, 8)}...`,
'AuthService'
);
// Check if thirdPartyId is already linked to another user
const [existingUser] = await this.userService.users({
where: { thirdPartyId }
});
if (existingUser) {
if (existingUser.id === userId) {
Logger.warn(
`linkOidcToUser: User ${userId.substring(0, 8)}... is already linked to this thirdPartyId`,
'AuthService'
);
// Already linked to the same user, just return token
return this.jwtService.sign({ id: userId });
}
Logger.warn(
`linkOidcToUser: thirdPartyId already linked to another user ${existingUser.id.substring(0, 8)}...`,
'AuthService'
);
throw new ConflictException(
'This OIDC account is already linked to another user'
);
}
// Get the current user
const user = await this.userService.user({ id: userId });
if (!user) {
Logger.error(
`linkOidcToUser: User ${userId.substring(0, 8)}... not found`,
'AuthService'
);
throw new Error('User not found');
}
if (user.provider !== 'ANONYMOUS') {
Logger.error(
`linkOidcToUser: User ${userId.substring(0, 8)}... has provider ${user.provider}, expected ANONYMOUS`,
'AuthService'
);
throw new Error('Only users with token authentication can link OIDC');
}
// Update user with thirdPartyId (keeping provider as ANONYMOUS for dual auth)
await this.userService.updateUser({
where: { id: userId },
data: { thirdPartyId }
});
Logger.log(
`linkOidcToUser: Successfully linked OIDC to user ${userId.substring(0, 8)}...`,
'AuthService'
);
return this.jwtService.sign({ id: userId });
}
}

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

@ -1,40 +1,119 @@
import { Logger } from '@nestjs/common';
import * as jwt from 'jsonwebtoken';
import ms from 'ms';
export interface OidcLinkState {
linkMode: boolean;
userId: string;
}
/**
* Custom state store for OIDC authentication that doesn't rely on express-session.
* This store manages OAuth2 state parameters in memory with automatic cleanup.
* Supports link mode for linking existing token-authenticated users to OIDC.
*/
export class OidcStateStore {
private readonly STATE_EXPIRY_MS = ms('10 minutes');
private pendingLinkState?: OidcLinkState;
private jwtSecret?: string;
private stateMap = new Map<
string,
{
appState?: unknown;
ctx: { issued?: Date; maxAge?: number; nonce?: string };
ctx: { issued?: string; maxAge?: number; nonce?: string };
linkState?: OidcLinkState;
meta?: unknown;
timestamp: number;
}
>();
/**
* Set the JWT secret for token validation in link mode
*/
public setJwtSecret(secret: string) {
this.jwtSecret = secret;
}
/**
* Store request state.
* Signature matches passport-openidconnect SessionStore
* Automatically extracts linkMode from request query params and validates JWT token
*/
public store(
_req: unknown,
req: unknown,
_meta: unknown,
appState: unknown,
ctx: { maxAge?: number; nonce?: string; issued?: Date },
ctx: { maxAge?: number; nonce?: string; issued?: string },
callback: (err: Error | null, handle?: string) => void
) {
try {
// Generate a unique handle for this state
const handle = this.generateHandle();
// Check if there's a pending link state from the controller
// or extract from request query params
let linkState = this.getPendingLinkState();
// If no pending state, check request query params for linkMode
if (!linkState) {
const request = req as {
query?: { linkMode?: string; token?: string };
headers?: { authorization?: string };
};
if (request?.query?.linkMode === 'true') {
// Get token from query param or Authorization header
let token = request?.query?.token;
if (
!token &&
request?.headers?.authorization?.startsWith('Bearer ')
) {
token = request.headers.authorization.substring(7);
}
if (token && this.jwtSecret) {
try {
const decoded = jwt.verify(token, this.jwtSecret) as {
id: string;
};
if (decoded?.id) {
linkState = {
linkMode: true,
userId: decoded.id
};
Logger.log(
`Link mode validated for user ${decoded.id.substring(0, 8)}... from request`,
'OidcStateStore'
);
}
} catch (error) {
Logger.warn(
`Failed to validate JWT in link mode: ${error instanceof Error ? error.message : 'Unknown error'}`,
'OidcStateStore'
);
}
} else {
Logger.warn(
'Link mode requested but no valid token provided',
'OidcStateStore'
);
}
}
}
const isLinkMode = linkState?.linkMode ?? false;
Logger.debug(
`Storing OIDC state with handle ${handle.substring(0, 8)}... (linkMode: ${isLinkMode})`,
'OidcStateStore'
);
this.stateMap.set(handle, {
appState,
ctx,
linkState,
meta: _meta,
timestamp: Date.now()
});
@ -44,6 +123,7 @@ export class OidcStateStore {
callback(null, handle);
} catch (error) {
Logger.error(`Error storing OIDC state: ${error}`, 'OidcStateStore');
callback(error as Error);
}
}
@ -51,25 +131,34 @@ export class OidcStateStore {
/**
* Verify request state.
* Signature matches passport-openidconnect SessionStore
* Attaches linkState directly to request for retrieval in validate()
*/
public verify(
_req: unknown,
req: unknown,
handle: string,
callback: (
err: Error | null,
appState?: unknown,
ctx?: { maxAge?: number; nonce?: string; issued?: Date }
ctx?: { maxAge?: number; nonce?: string; issued?: string },
state?: unknown
) => void
) {
try {
const data = this.stateMap.get(handle);
if (!data) {
Logger.debug(
`OIDC state not found for handle ${handle.substring(0, 8)}...`,
'OidcStateStore'
);
return callback(null, undefined, undefined);
}
if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) {
// State has expired
Logger.debug(
`OIDC state expired for handle ${handle.substring(0, 8)}...`,
'OidcStateStore'
);
this.stateMap.delete(handle);
return callback(null, undefined, undefined);
}
@ -77,8 +166,24 @@ export class OidcStateStore {
// Remove state after verification (one-time use)
this.stateMap.delete(handle);
const isLinkMode = data.linkState?.linkMode ?? false;
Logger.debug(
`Verified OIDC state for handle ${handle.substring(0, 8)}... (linkMode: ${isLinkMode})`,
'OidcStateStore'
);
// Attach linkState directly to request object for retrieval in validate()
if (data.linkState) {
(req as any).oidcLinkState = data.linkState;
Logger.log(
`Attached linkState to request for user ${data.linkState.userId.substring(0, 8)}...`,
'OidcStateStore'
);
}
callback(null, data.ctx, data.appState);
} catch (error) {
Logger.error(`Error verifying OIDC state: ${error}`, 'OidcStateStore');
callback(error as Error);
}
}
@ -111,4 +216,25 @@ export class OidcStateStore {
Date.now().toString(36)
);
}
/**
* Set link state for an existing or upcoming state entry.
* This allows the controller to attach user information before the OIDC flow starts.
*/
public setLinkStateForNextStore(linkState: OidcLinkState) {
this.pendingLinkState = linkState;
Logger.log(
`Link state prepared for user ${linkState.userId.substring(0, 8)}...`,
'OidcStateStore'
);
}
/**
* Get and clear pending link state (used internally by store)
*/
public getPendingLinkState(): OidcLinkState | undefined {
const linkState = this.pendingLinkState;
this.pendingLinkState = undefined;
return linkState;
}
}

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

@ -11,25 +11,45 @@ import {
OidcParams,
OidcProfile
} from './interfaces/interfaces';
import { OidcStateStore } from './oidc-state.store';
import { OidcLinkState, OidcStateStore } from './oidc-state.store';
export interface OidcValidationResult {
jwt?: string;
linkState?: OidcLinkState;
thirdPartyId: string;
}
export interface OidcStrategyOptions extends StrategyOptions {
jwtSecret?: string;
}
@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
private static readonly stateStore = new OidcStateStore();
public static getStateStore(): OidcStateStore {
return OidcStrategy.stateStore;
}
public constructor(
private readonly authService: AuthService,
options: StrategyOptions
options: OidcStrategyOptions
) {
super({
...options,
passReqToCallback: true,
store: OidcStrategy.stateStore
});
// Configure JWT secret for link mode validation
if (options.jwtSecret) {
OidcStrategy.stateStore.setJwtSecret(options.jwtSecret);
Logger.debug('JWT secret configured for OIDC link mode', 'OidcStrategy');
}
}
public async validate(
_request: Request,
request: Request,
issuer: string,
profile: OidcProfile,
context: OidcContext,
@ -46,11 +66,6 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
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}`,
@ -60,7 +75,38 @@ export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
throw new Error('Missing subject identifier in OIDC response');
}
return { jwt };
// Check if this is a link mode request
// The linkState is attached to the request by OidcStateStore.verify()
const linkState = (request as any).oidcLinkState as
| OidcLinkState
| undefined;
if (linkState?.linkMode) {
Logger.log(
`OidcStrategy: Link mode detected for user ${linkState.userId.substring(0, 8)}...`,
'OidcStrategy'
);
// In link mode, we don't validate OAuth login (which would create a new user)
// Instead, we return the thirdPartyId for the controller to link
return {
linkState,
thirdPartyId
} as OidcValidationResult;
}
// Normal OIDC login flow
Logger.debug(
`OidcStrategy: Normal login flow for thirdPartyId ${thirdPartyId.substring(0, 8)}...`,
'OidcStrategy'
);
const jwt = await this.authService.validateOAuthLogin({
thirdPartyId,
provider: Provider.OIDC
});
return { jwt, thirdPartyId } as OidcValidationResult;
} catch (error) {
Logger.error(error, 'OidcStrategy');
throw error;

12
apps/api/src/app/user/user.service.ts

@ -97,7 +97,15 @@ export class UserService {
}
public async getUser(
{ accounts, id, permissions, settings, subscription }: UserWithSettings,
{
accounts,
id,
permissions,
provider,
settings,
subscription,
thirdPartyId
}: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const userData = await Promise.all([
@ -150,9 +158,11 @@ export class UserService {
activitiesCount,
id,
permissions,
provider,
subscription,
systemMessage,
tags,
thirdPartyId,
access: access.map((accessItem) => {
return {
alias: accessItem.alias,

92
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -37,11 +37,11 @@ import {
MatSlideToggleModule
} from '@angular/material/slide-toggle';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RouterModule } from '@angular/router';
import { ActivatedRoute, RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { format, parseISO } from 'date-fns';
import { addIcons } from 'ionicons';
import { eyeOffOutline, eyeOutline } from 'ionicons/icons';
import { eyeOffOutline, eyeOutline, linkOutline } from 'ionicons/icons';
import ms from 'ms';
import { EMPTY, Subject, throwError } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
@ -68,10 +68,14 @@ import { catchError, takeUntil } from 'rxjs/operators';
export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
public appearancePlaceholder = $localize`Auto`;
public baseCurrency: string;
public canLinkOidc: boolean = false;
public currencies: string[] = [];
public deleteOwnUserForm = this.formBuilder.group({
accessToken: ['', Validators.required]
});
public hasOidcLinked: boolean = false;
public hasPermissionForAuthOidc: boolean = false;
public hasPermissionForAuthToken: boolean = false;
public hasPermissionToDeleteOwnUser: boolean;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
@ -100,6 +104,7 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private activatedRoute: ActivatedRoute,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private formBuilder: FormBuilder,
@ -110,17 +115,42 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
private userService: UserService,
public webAuthnService: WebAuthnService
) {
const { baseCurrency, currencies } = this.dataService.fetchInfo();
const { baseCurrency, currencies, globalPermissions } =
this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.currencies = currencies;
// Check global permissions for auth methods
this.hasPermissionForAuthOidc = hasPermission(
globalPermissions,
permissions.enableAuthOidc
);
this.hasPermissionForAuthToken = hasPermission(
globalPermissions,
permissions.enableAuthToken
);
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
// Check if user can link OIDC
// Both OIDC and Token auth must be enabled to show linking feature
// Only show for users with token auth (provider ANONYMOUS)
this.hasOidcLinked =
this.hasPermissionForAuthOidc &&
this.hasPermissionForAuthToken &&
this.user.provider === 'ANONYMOUS' &&
!!this.user.thirdPartyId;
this.canLinkOidc =
this.hasPermissionForAuthOidc &&
this.hasPermissionForAuthToken &&
this.user.provider === 'ANONYMOUS' &&
!this.user.thirdPartyId;
this.hasPermissionToDeleteOwnUser = hasPermission(
this.user.permissions,
permissions.deleteOwnUser
@ -143,11 +173,42 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
}
});
addIcons({ eyeOffOutline, eyeOutline });
addIcons({ eyeOffOutline, eyeOutline, linkOutline });
}
public ngOnInit() {
this.update();
// Handle query params for link results
this.activatedRoute.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['linkSuccess'] === 'true') {
this.snackBar.open(
$localize`Your OIDC account has been successfully linked.`,
undefined,
{ duration: ms('5 seconds') }
);
// Refresh user data
this.userService.get(true).subscribe();
} else if (params['linkError']) {
let errorMessage = $localize`Failed to link OIDC account.`;
switch (params['linkError']) {
case 'already-linked':
errorMessage = $localize`This OIDC account is already linked to another user.`;
break;
case 'invalid-session':
errorMessage = $localize`Your session is invalid. Please log in again.`;
break;
case 'invalid-provider':
errorMessage = $localize`Only token-authenticated users can link OIDC.`;
break;
}
this.snackBar.open(errorMessage, undefined, {
duration: ms('5 seconds')
});
}
});
}
public isCommunityLanguage() {
@ -178,6 +239,29 @@ export class GfUserAccountSettingsComponent implements OnDestroy, OnInit {
});
}
public onLinkOidc() {
this.notificationService.confirm({
confirmFn: () => {
// Get current JWT token and navigate to OIDC with linkMode
const token = this.tokenStorageService.getToken();
if (token) {
// Navigate to OIDC endpoint with linkMode and token
window.location.href = `../api/auth/oidc?linkMode=true&token=${encodeURIComponent(token)}`;
} else {
this.snackBar.open(
$localize`Unable to initiate linking. Please log in again.`,
undefined,
{ duration: ms('3 seconds') }
);
}
},
confirmType: ConfirmationDialogType.Warn,
discardLabel: $localize`Cancel`,
title: $localize`Link OIDC Provider`,
message: $localize`This will link your current account to an OIDC provider. After linking, you will be able to sign in using both your Security Token and OIDC. This action cannot be undone. Do you want to continue?`
});
}
public onCloseAccount() {
this.notificationService.confirm({
confirmFn: () => {

23
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -261,6 +261,29 @@
</button>
</div>
</div>
@if (canLinkOidc || hasOidcLinked) {
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50">
<div i18n>OIDC Provider</div>
<div class="hint-text text-muted" i18n>
Link your account to sign in with OpenID Connect
</div>
</div>
<div class="pl-1 w-50">
@if (hasOidcLinked) {
<span class="badge badge-success" i18n>
<ion-icon class="mr-1" name="link-outline"></ion-icon>
Linked
</span>
} @else {
<button color="primary" mat-flat-button (click)="onLinkOidc()">
<ion-icon class="mr-2" name="link-outline"></ion-icon>
<span i18n>Link OIDC Provider</span>
</button>
}
</div>
</div>
}
@if (hasPermissionToDeleteOwnUser) {
<hr class="mt-5" />
<form

4
libs/common/src/lib/interfaces/user.interface.ts

@ -1,7 +1,7 @@
import { SubscriptionType } from '@ghostfolio/common/enums';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import { Access, Tag } from '@prisma/client';
import { Access, Provider, Tag } from '@prisma/client';
import { SubscriptionOffer } from './subscription-offer.interface';
import { SystemMessage } from './system-message.interface';
@ -15,6 +15,7 @@ export interface User {
dateOfFirstActivity: Date;
id: string;
permissions: string[];
provider: Provider;
settings: UserSettings;
systemMessage?: SystemMessage;
subscription: {
@ -23,4 +24,5 @@ export interface User {
type: SubscriptionType;
};
tags: (Tag & { isUsed: boolean })[];
thirdPartyId?: string;
}

Loading…
Cancel
Save