diff --git a/apps/lfx-one/src/app/app.config.ts b/apps/lfx-one/src/app/app.config.ts index 61e1ada53..8b78f25ee 100644 --- a/apps/lfx-one/src/app/app.config.ts +++ b/apps/lfx-one/src/app/app.config.ts @@ -37,7 +37,7 @@ export const appConfig: ApplicationConfig = { providers: [ provideZonelessChangeDetection(), { provide: ErrorHandler, useClass: ChunkLoadErrorHandler }, - provideRouter(routes, withPreloading(CustomPreloadingStrategy), withInMemoryScrolling({ scrollPositionRestoration: 'top' })), + provideRouter(routes, withPreloading(CustomPreloadingStrategy), withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })), provideClientHydration(withEventReplay(), withIncrementalHydration(), withHttpTransferCacheOptions({ includeHeaders: ['Authorization'] })), provideHttpClient(withFetch(), withInterceptors([authenticationInterceptor, ssrBaseUrlInterceptor])), provideAnimationsAsync(), diff --git a/apps/lfx-one/src/app/modules/profile/developer/profile-developer.component.html b/apps/lfx-one/src/app/modules/profile/developer/profile-developer.component.html deleted file mode 100644 index e150538c9..000000000 --- a/apps/lfx-one/src/app/modules/profile/developer/profile-developer.component.html +++ /dev/null @@ -1,91 +0,0 @@ - - - -
- - - -
-

Your API token provides programmatic access to LFX services. Keep this token secure and never share it publicly.

-
-
-
- -
- - -
-
-

API Token

-

Your personal access token for API authentication

-
- - @if (isLoading()) { -
- - Loading token... -
- } @else { -
- -
-
-
- -
-
-
- Personal Access Token -
-
- {{ maskToken() ? maskedToken() : apiToken() }} -
-
-
- - -
- - - - -
-
- - -
-
-
- -
-
-

Security Guidelines

-
-
    -
  • This token is short-lived and automatically regenerated by the system
  • -
  • Never commit this token to version control
  • -
-
-
-
-
-
- } -
-
-
-
diff --git a/apps/lfx-one/src/app/modules/profile/developer/profile-developer.component.ts b/apps/lfx-one/src/app/modules/profile/developer/profile-developer.component.ts deleted file mode 100644 index b6052b03c..000000000 --- a/apps/lfx-one/src/app/modules/profile/developer/profile-developer.component.ts +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright The Linux Foundation and each contributor to LFX. -// SPDX-License-Identifier: MIT - -import { Clipboard } from '@angular/cdk/clipboard'; -import { Component, computed, inject, Signal, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ButtonComponent } from '@components/button/button.component'; -import { CardComponent } from '@components/card/card.component'; -import { MessageComponent } from '@components/message/message.component'; -import { UserService } from '@services/user.service'; -import { MessageService } from 'primeng/api'; -import { ToastModule } from 'primeng/toast'; -import { finalize } from 'rxjs'; - -@Component({ - selector: 'lfx-profile-developer', - imports: [ButtonComponent, CardComponent, MessageComponent, ToastModule], - templateUrl: './profile-developer.component.html', -}) -export class ProfileDeveloperComponent { - private readonly userService = inject(UserService); - private readonly messageService = inject(MessageService); - private readonly clipboard = inject(Clipboard); - - // Loading state - public loading = signal(true); - - // Token data using toSignal pattern - public tokenInfo = this.initializeTokenInfo(); - - // Loading state computed from tokenInfo - public readonly isLoading = computed(() => this.loading()); - - // API token computed from tokenInfo - public readonly apiToken = computed(() => this.tokenInfo()?.token || ''); - - // Token visibility toggle - public maskToken = signal(true); - - // Computed masked token - public readonly maskedToken = computed(() => { - const token = this.apiToken(); - if (!token) return ''; - if (token.length <= 8) return '*'.repeat(token.length); - // Show first 4 chars + fixed number of asterisks + last 4 chars for better UX - return token.substring(0, 4) + '••••••••••••' + token.substring(token.length - 4); - }); - - public toggleTokenVisibility(): void { - this.maskToken.set(!this.maskToken()); - } - - public copyToken(): void { - const token = this.apiToken(); - if (!token) { - this.messageService.add({ - severity: 'warn', - summary: 'No Token', - detail: 'No API token available to copy.', - }); - return; - } - - const success = this.clipboard.copy(token); - if (success) { - this.messageService.add({ - severity: 'success', - summary: 'Copied', - detail: 'API token copied to clipboard successfully.', - }); - } else { - this.messageService.add({ - severity: 'error', - summary: 'Copy Failed', - detail: 'Failed to copy token to clipboard. Please try again.', - }); - } - } - - private initializeTokenInfo(): Signal<{ token: string; type: string } | null> { - this.loading.set(true); - return toSignal(this.userService.getDeveloperTokenInfo().pipe(finalize(() => this.loading.set(false))), { initialValue: null }); - } -} diff --git a/apps/lfx-one/src/app/modules/profile/profile.routes.ts b/apps/lfx-one/src/app/modules/profile/profile.routes.ts index a42437485..4e84aa791 100644 --- a/apps/lfx-one/src/app/modules/profile/profile.routes.ts +++ b/apps/lfx-one/src/app/modules/profile/profile.routes.ts @@ -41,10 +41,6 @@ export const PROFILE_ROUTES: Routes = [ path: 'email', loadComponent: () => import('./email/profile-email.component').then((m) => m.ProfileEmailComponent), }, - { - path: 'developer', - loadComponent: () => import('./developer/profile-developer.component').then((m) => m.ProfileDeveloperComponent), - }, // Backward-compat redirects for old URLs { path: 'overview', redirectTo: 'attribution' }, diff --git a/apps/lfx-one/src/app/modules/settings/account-settings/account-settings.component.html b/apps/lfx-one/src/app/modules/settings/account-settings/account-settings.component.html index a43dc4f50..805d567eb 100644 --- a/apps/lfx-one/src/app/modules/settings/account-settings/account-settings.component.html +++ b/apps/lfx-one/src/app/modules/settings/account-settings/account-settings.component.html @@ -490,37 +490,77 @@

API Token

Loading token... } @else if (developerToken()) { -
-
-
- -
-

Personal Access Token

-

- {{ showToken() ? developerToken() : maskedToken() }} -

+
+ +
+
+
+ +
+

Personal Access Token

+

+ {{ maskedToken() }} +

+
+
+
+ + + +
-
-
- - - -
+ + + @if (developerV1Token()) { +
+
+
+ +
+

API Token (v1)

+

For legacy v1 APIs (api-gw.platform.linuxfoundation.org)

+

+ {{ maskedV1Token() }} +

+
+
+
+ + + + +
+
+
+ }
} @else {
diff --git a/apps/lfx-one/src/app/modules/settings/account-settings/account-settings.component.ts b/apps/lfx-one/src/app/modules/settings/account-settings/account-settings.component.ts index 4ddeb43ff..5623145c2 100644 --- a/apps/lfx-one/src/app/modules/settings/account-settings/account-settings.component.ts +++ b/apps/lfx-one/src/app/modules/settings/account-settings/account-settings.component.ts @@ -8,12 +8,14 @@ import { AbstractControl, FormBuilder, FormControl, FormGroup, ReactiveFormsModu import { BadgeComponent } from '@components/badge/badge.component'; import { ButtonComponent } from '@components/button/button.component'; import { InputTextComponent } from '@components/input-text/input-text.component'; +import { TokenRevealDialogComponent } from '@components/token-reveal-dialog/token-reveal-dialog.component'; import { markFormControlsAsTouched } from '@lfx-one/shared'; import { useResendCooldown } from '@shared/utils/resend-cooldown'; import { ChangePasswordRequest, EmailManagementData, PasswordStrength, UserEmail } from '@lfx-one/shared/interfaces'; import { UserService } from '@services/user.service'; import { ConfirmationService, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { DialogService, DynamicDialogModule } from 'primeng/dynamicdialog'; import { ToastModule } from 'primeng/toast'; import { TooltipModule } from 'primeng/tooltip'; import { HttpErrorResponse } from '@angular/common/http'; @@ -22,8 +24,18 @@ import { BehaviorSubject, catchError, finalize, of, switchMap, take } from 'rxjs @Component({ selector: 'lfx-account-settings', host: { class: 'block' }, - imports: [NgClass, ReactiveFormsModule, BadgeComponent, ButtonComponent, InputTextComponent, ConfirmDialogModule, ToastModule, TooltipModule], - providers: [ConfirmationService, MessageService], + imports: [ + NgClass, + ReactiveFormsModule, + BadgeComponent, + ButtonComponent, + InputTextComponent, + ConfirmDialogModule, + ToastModule, + TooltipModule, + DynamicDialogModule, + ], + providers: [ConfirmationService, MessageService, DialogService], templateUrl: './account-settings.component.html', }) export class AccountSettingsComponent { @@ -31,6 +43,7 @@ export class AccountSettingsComponent { private readonly userService = inject(UserService); private readonly confirmationService = inject(ConfirmationService); private readonly messageService = inject(MessageService); + private readonly dialogService = inject(DialogService); private readonly platformId = inject(PLATFORM_ID); private readonly destroyRef = inject(DestroyRef); @@ -92,16 +105,16 @@ export class AccountSettingsComponent { // DEVELOPER SETTINGS // ══════════════════════════════════════════ + // v2 OIDC session token (audience PCC_AUTH0_AUDIENCE) public developerToken = signal(''); - public showToken = signal(false); + // v1 API Gateway token (audience api-gw.*) — empty when the server did not return one + public developerV1Token = signal(''); public loadingToken = signal(true); - public tokenCopied = signal(false); + // Tracks which token's Copy button most recently succeeded, so only that button shows "Copied!" + public tokenCopied = signal<'v2' | 'v1' | null>(null); - public maskedToken = computed(() => { - const token = this.developerToken(); - if (!token || token.length <= 8) return token; - return `${token.slice(0, 4)}${'*'.repeat(11)}${token.slice(-4)}`; - }); + public maskedToken = computed(() => this.maskTokenValue(this.developerToken())); + public maskedV1Token = computed(() => this.maskTokenValue(this.developerV1Token())); // ══════════════════════════════════════════ // PASSWORD @@ -384,19 +397,33 @@ export class AccountSettingsComponent { // DEVELOPER SETTINGS PUBLIC METHODS // ══════════════════════════════════════════ - public toggleTokenVisibility(): void { - this.showToken.set(!this.showToken()); + public openTokenPopup(title: string, token: string): void { + if (!token) return; + this.dialogService.open(TokenRevealDialogComponent, { + header: title, + width: '40rem', + modal: true, + draggable: false, + resizable: false, + dismissableMask: true, + style: { maxWidth: '90vw' }, + data: { token }, + }); } - public copyToken(): void { - const token = this.developerToken(); + public copyToken(token: string, kind: 'v2' | 'v1'): void { if (!token || !isPlatformBrowser(this.platformId)) return; - navigator.clipboard.writeText(token).then(() => { - this.tokenCopied.set(true); - this.messageService.add({ severity: 'success', summary: 'Copied', detail: 'Token copied to clipboard' }); - setTimeout(() => this.tokenCopied.set(false), 2000); - }); + navigator.clipboard + .writeText(token) + .then(() => { + this.tokenCopied.set(kind); + this.messageService.add({ severity: 'success', summary: 'Copied', detail: 'Token copied to clipboard' }); + setTimeout(() => this.tokenCopied.set(null), 2000); + }) + .catch(() => { + this.messageService.add({ severity: 'error', summary: 'Copy Failed', detail: 'Failed to copy token to clipboard. Please try again.' }); + }); } // ══════════════════════════════════════════ @@ -424,11 +451,24 @@ export class AccountSettingsComponent { .getDeveloperTokenInfo() .pipe(finalize(() => this.loadingToken.set(false))) .subscribe({ - next: (info) => this.developerToken.set(info.token), - error: () => this.developerToken.set(''), + next: (info) => { + // Guard the shape at runtime: a non-string (e.g. null on a transient error path) resets + // to empty rather than leaking a raw value through maskTokenValue. + this.developerToken.set(typeof info.token === 'string' ? info.token : ''); + this.developerV1Token.set(typeof info.v1Token === 'string' ? info.v1Token : ''); + }, + error: () => { + this.developerToken.set(''); + this.developerV1Token.set(''); + }, }); } + private maskTokenValue(token: string): string { + if (!token || token.length <= 8) return token; + return `${token.slice(0, 4)}${'*'.repeat(11)}${token.slice(-4)}`; + } + private calculatePasswordStrength(password: string): PasswordStrength { const requirements = { minLength: password.length >= 8, diff --git a/apps/lfx-one/src/app/shared/components/header/header.component.ts b/apps/lfx-one/src/app/shared/components/header/header.component.ts index 3d208ce0e..b175960ff 100644 --- a/apps/lfx-one/src/app/shared/components/header/header.component.ts +++ b/apps/lfx-one/src/app/shared/components/header/header.component.ts @@ -58,7 +58,8 @@ export class HeaderComponent { { label: 'Developer Settings', icon: 'fa-light fa-cog', - routerLink: '/profile/developer', + routerLink: '/settings', + fragment: 'developer-settings', }, { separator: true, diff --git a/apps/lfx-one/src/app/shared/components/token-reveal-dialog/token-reveal-dialog.component.html b/apps/lfx-one/src/app/shared/components/token-reveal-dialog/token-reveal-dialog.component.html new file mode 100644 index 000000000..bebb14f61 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/token-reveal-dialog/token-reveal-dialog.component.html @@ -0,0 +1,19 @@ + + + +
+

Copy your full token below. Keep it secure and never share it publicly.

+
+ {{ token }} +
+ + +
+ + + + +
+
diff --git a/apps/lfx-one/src/app/shared/components/token-reveal-dialog/token-reveal-dialog.component.ts b/apps/lfx-one/src/app/shared/components/token-reveal-dialog/token-reveal-dialog.component.ts new file mode 100644 index 000000000..190e21498 --- /dev/null +++ b/apps/lfx-one/src/app/shared/components/token-reveal-dialog/token-reveal-dialog.component.ts @@ -0,0 +1,59 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { Clipboard } from '@angular/cdk/clipboard'; +import { Component, inject } from '@angular/core'; +import { ButtonComponent } from '@components/button/button.component'; +import { MessageService } from 'primeng/api'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +/** + * Reveal popup for a developer API token. Opened via DialogService.open() with the full + * token passed as dialog data; displays it in a wrapped, vertically-scrollable box with + * Copy and Close. The dialog title is set by the opener via the DialogService config header. + */ +@Component({ + selector: 'lfx-token-reveal-dialog', + imports: [ButtonComponent], + templateUrl: './token-reveal-dialog.component.html', +}) +export class TokenRevealDialogComponent { + private readonly dialogRef = inject(DynamicDialogRef); + private readonly dialogConfig = inject(DynamicDialogConfig); + private readonly clipboard = inject(Clipboard); + private readonly messageService = inject(MessageService); + + // Guard against a missing/non-string token in the dialog data so we never render the literal + // string "undefined"; the single in-app opener always passes a string, this is defense in depth. + public readonly token = typeof this.dialogConfig.data?.token === 'string' ? this.dialogConfig.data.token : ''; + + public copy(): void { + if (!this.token) { + this.messageService.add({ + severity: 'warn', + summary: 'No Token', + detail: 'No API token available to copy.', + }); + return; + } + + const success = this.clipboard.copy(this.token); + if (success) { + this.messageService.add({ + severity: 'success', + summary: 'Copied', + detail: 'API token copied to clipboard successfully.', + }); + } else { + this.messageService.add({ + severity: 'error', + summary: 'Copy Failed', + detail: 'Failed to copy token to clipboard. Please try again.', + }); + } + } + + public close(): void { + this.dialogRef.close(); + } +} diff --git a/apps/lfx-one/src/app/shared/services/user.service.ts b/apps/lfx-one/src/app/shared/services/user.service.ts index b4f35d2fa..fb0647af1 100644 --- a/apps/lfx-one/src/app/shared/services/user.service.ts +++ b/apps/lfx-one/src/app/shared/services/user.service.ts @@ -12,6 +12,7 @@ import { CombinedProfile, ClaimAliasRequest, CreateUserPermissionRequest, + DeveloperTokenInfo, EmailManagementData, EnrichedIdentity, Impersonator, @@ -167,8 +168,8 @@ export class UserService { /** * Get developer token information */ - public getDeveloperTokenInfo(): Observable<{ token: string; type: string }> { - return this.http.get<{ token: string; type: string }>('/api/profile/developer').pipe(take(1)); + public getDeveloperTokenInfo(): Observable { + return this.http.get('/api/profile/developer').pipe(take(1)); } /** diff --git a/apps/lfx-one/src/server/controllers/profile.controller.ts b/apps/lfx-one/src/server/controllers/profile.controller.ts index d2042b1e0..85cb28022 100644 --- a/apps/lfx-one/src/server/controllers/profile.controller.ts +++ b/apps/lfx-one/src/server/controllers/profile.controller.ts @@ -16,6 +16,7 @@ import { CdpWorkExperienceRequest, ClaimAliasRequest, CombinedProfile, + DeveloperTokenInfo, EmailManagementData, EnrichedIdentity, IdentityDisplayState, @@ -756,15 +757,22 @@ export class ProfileController { return next(validationError); } - // Return token information - const tokenInfo = { + // The v1 API Gateway token (audience api-gw.*) is minted by the auth middleware via + // refresh-token exchange and stored on req.apiGatewayToken. Surface it alongside the v2 + // token so users migrating off the ID dashboard can keep calling v1 APIs. Only include the + // key when present so the UI can hide the v1 row when the exchange produced nothing. + const v1Token = req.apiGatewayToken; + + const tokenInfo: DeveloperTokenInfo = { token: bearerToken, type: 'Bearer', + ...(v1Token ? { v1Token } : {}), }; logger.success(req, 'get_developer_token_info', startTime, { user_id: userId, token_length: bearerToken.length, + has_v1_token: Boolean(v1Token), }); // Set cache headers to prevent caching of sensitive bearer tokens diff --git a/packages/shared/src/interfaces/profile.interface.ts b/packages/shared/src/interfaces/profile.interface.ts index cff5c8037..2322ddc18 100644 --- a/packages/shared/src/interfaces/profile.interface.ts +++ b/packages/shared/src/interfaces/profile.interface.ts @@ -1,6 +1,20 @@ // Copyright The Linux Foundation and each contributor to LFX. // SPDX-License-Identifier: MIT +/** + * Developer settings token info returned by GET /api/profile/developer. + * + * `token` is the user's v2 OIDC session token (audience PCC_AUTH0_AUDIENCE). + * `v1Token` is the user-scoped v1 API Gateway token (audience api-gw.*), minted via + * refresh-token exchange in the auth middleware and surfaced for users still calling v1 APIs. + * It is omitted when unavailable (no refresh token / exchange failed) so the UI can hide the row. + */ +export interface DeveloperTokenInfo { + token: string; + type: string; + v1Token?: string; +} + /** * Profile tab configuration for the profile layout navigation */