Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ export class ProfileLayoutComponent {
routerLink: '/profile/email',
routerLinkActiveOptions: { exact: true },
},
{
label: 'Developer Settings',
icon: 'fa-light fa-code text-purple-500',
routerLink: '/profile/developer',
routerLinkActiveOptions: { exact: true },
},
]);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->

<div class="container mx-auto pb-8">
<!-- Developer Settings Info Banner -->
<lfx-message severity="info" icon="fa-light fa-code" styleClass="mb-6" title="Developer API Access">
<ng-template #content>
<div class="flex flex-col gap-1">
<p class="text-sm">Your API token provides programmatic access to LFX services. Keep this token secure and never share it publicly.</p>
</div>
</ng-template>
</lfx-message>

<div class="flex flex-col gap-6">
<!-- API Token Section -->
<lfx-card>
<div class="flex flex-col gap-6">
<div>
<h3 class="text-base font-medium text-gray-900" data-testid="developer-token-heading">API Token</h3>
<p class="text-sm text-gray-500 mt-1">Your personal access token for API authentication</p>
</div>

@if (isLoading()) {
<div class="flex items-center justify-center py-4" data-testid="developer-token-loading">
<i class="fa-light fa-circle-notch fa-spin text-2xl text-blue-400"></i>
<span class="ml-3 text-gray-600">Loading token...</span>
</div>
} @else {
<div class="space-y-4">
<!-- Token Display Section -->
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg gap-3" data-testid="api-token-container">
<div class="flex items-center space-x-4 flex-1">
<div class="flex-shrink-0">
<i class="fa-light fa-key text-purple-500 text-xl"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2">
<span class="text-sm font-medium text-gray-900">Personal Access Token</span>
</div>
<div class="text-sm font-mono text-gray-600 mt-1 truncate break-all whitespace-pre-wrap" data-testid="api-token-display">
{{ maskToken() ? maskedToken() : apiToken() }}
</div>
</div>
</div>

<!-- Action Buttons -->
<div class="flex items-center gap-2">
<lfx-button
type="button"
size="small"
[icon]="maskToken() ? 'fa-light fa-eye' : 'fa-light fa-eye-slash'"
severity="secondary"
label="Show"
(onClick)="toggleTokenVisibility()"
[attr.data-testid]="'token-visibility-toggle'">
</lfx-button>
<lfx-button
type="button"
size="small"
icon="fa-light fa-copy"
label="Copy"
severity="primary"
(onClick)="copyToken()"
data-testid="copy-token-button">
</lfx-button>
</div>
</div>

<!-- Token Usage Guidelines -->
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div class="flex">
<div class="flex-shrink-0">
<i class="fa-light fa-exclamation-triangle text-amber-600"></i>
</div>
<div class="ml-3">
<h4 class="text-sm font-medium text-amber-800">Security Guidelines</h4>
<div class="mt-2 text-sm text-amber-700">
<ul class="list-disc list-inside space-y-1">
<li>This token is short-lived and automatically regenerated by the system</li>
<li>Never commit this token to version control</li>
</ul>
</div>
</div>
</div>
</div>
</div>
}
</div>
</lfx-card>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Copyright The Linux Foundation and each contributor to LFX.
// SPDX-License-Identifier: MIT

import { Clipboard } from '@angular/cdk/clipboard';
import { CommonModule } from '@angular/common';
import { Component, computed, inject, Signal, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ButtonComponent } from '@shared/components/button/button.component';
import { CardComponent } from '@shared/components/card/card.component';
import { MessageComponent } from '@shared/components/message/message.component';
import { UserService } from '@shared/services/user.service';
import { MessageService } from 'primeng/api';
import { ToastModule } from 'primeng/toast';
import { finalize } from 'rxjs';

@Component({
selector: 'lfx-profile-developer',
standalone: true,
imports: [CommonModule, ButtonComponent, CardComponent, MessageComponent, ToastModule],
providers: [],
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<boolean>(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<boolean>(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 });
}
}
4 changes: 4 additions & 0 deletions apps/lfx-one/src/app/modules/profile/profile.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ 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),
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,10 @@ <h3 class="text-sm font-display text-gray-500">Recently Updated Committees</h3>
<i class="fa-light fa-people-group text-4xl text-gray-400 mb-4"></i>
<h3 class="text-lg font-medium text-gray-900 mb-2">No Committees</h3>
<p class="text-gray-600 mb-4 text-sm">This project doesn't have any committees yet.</p>
<lfx-button label="Add Committee" icon="fa-light fa-people-group" severity="secondary" size="small" (onClick)="openCreateDialog()">
</lfx-button>
@if (project()?.writer) {
<lfx-button label="Add Committee" icon="fa-light fa-people-group" severity="secondary" size="small" (onClick)="openCreateDialog()">
</lfx-button>
}
</div>
</div>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class HeaderComponent {
{
label: 'Developer Settings',
icon: 'fa-light fa-cog',
target: '_blank',
routerLink: '/profile/developer',
},
{
separator: true,
Expand Down
7 changes: 7 additions & 0 deletions apps/lfx-one/src/app/shared/services/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,11 @@ export class UserService {
public getTwoFactorSettings(): Observable<TwoFactorSettings> {
return this.http.get<TwoFactorSettings>('/api/profile/2fa-settings').pipe(take(1));
}

/**
* 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));
}
}
62 changes: 62 additions & 0 deletions apps/lfx-one/src/server/controllers/profile.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,4 +598,66 @@ export class ProfileController {
next(error);
}
}

/**
* GET /api/profile/developer - Get current user's developer token information
*/
public async getDeveloperTokenInfo(req: Request, res: Response, next: NextFunction): Promise<void> {
const startTime = Logger.start(req, 'get_developer_token_info');

try {
// Get user ID from auth context
const userId = await getUsernameFromAuth(req);

if (!userId) {
Logger.error(req, 'get_developer_token_info', startTime, new Error('User not authenticated or user ID not found'));

const validationError = ServiceValidationError.forField('user_id', 'User authentication required', {
operation: 'get_developer_token_info',
service: 'profile_controller',
path: req.path,
});

return next(validationError);
}

// Extract the bearer token from the request (set by auth middleware)
const bearerToken = req.bearerToken;

if (!bearerToken) {
Logger.error(req, 'get_developer_token_info', startTime, new Error('No bearer token available'));

const validationError = ServiceValidationError.forField('token', 'No API token available for user', {
operation: 'get_developer_token_info',
service: 'profile_controller',
path: req.path,
});

return next(validationError);
}

// Return token information
const tokenInfo = {
token: bearerToken,
type: 'Bearer',
};

Logger.success(req, 'get_developer_token_info', startTime, {
user_id: userId,
token_length: bearerToken.length,
});

// Set cache headers to prevent caching of sensitive bearer tokens
res.set({
['Cache-Control']: 'no-store, no-cache, must-revalidate, private',
Pragma: 'no-cache',
Expires: '0',
});

res.json(tokenInfo);
} catch (error) {
Logger.error(req, 'get_developer_token_info', startTime, error);
next(error);
}
}
}
3 changes: 3 additions & 0 deletions apps/lfx-one/src/server/routes/profile.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ router.get('/email-preferences', (req, res, next) => profileController.getEmailP
// PUT /api/profile/email-preferences - Update user email preferences
router.put('/email-preferences', (req, res, next) => profileController.updateEmailPreferences(req, res, next));

// GET /api/profile/developer - Get current user's developer token information
router.get('/developer', (req, res, next) => profileController.getDeveloperTokenInfo(req, res, next));

export default router;