Skip to content

Commit 6d25c08

Browse files
jordaneclaude
andcommitted
feat(profile): add developer settings page with API token display
Create comprehensive developer settings page accessible via user menu. Features secure API token display with masking, copy functionality, and proper authentication. Includes responsive table design and security guidelines for developers. Key features: - Real-time API token retrieval from OIDC bearer token - Token masking with fixed-width display for better UX - One-click copy to clipboard with toast notifications - Security best practices documentation - Responsive table layout with consistent styling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Jordan Evans <jevans@linuxfoundation.org>
1 parent a08bc3a commit 6d25c08

File tree

7 files changed

+272
-1
lines changed

7 files changed

+272
-1
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="container mx-auto pb-8">
5+
<!-- Developer Settings Info Banner -->
6+
<lfx-message severity="info" icon="fa-light fa-code" styleClass="mb-6" title="Developer API Access">
7+
<ng-template #content>
8+
<div class="flex flex-col gap-1">
9+
<p class="text-sm">
10+
Your API token provides programmatic access to LFX services. Keep this token secure and never share it publicly.
11+
</p>
12+
</div>
13+
</ng-template>
14+
</lfx-message>
15+
16+
<div class="flex flex-col gap-6">
17+
<!-- API Token Section -->
18+
<lfx-card>
19+
<div class="flex flex-col gap-6">
20+
<div>
21+
<h3 class="text-base font-medium text-gray-900" data-testid="developer-token-heading">API Token</h3>
22+
<p class="text-sm text-gray-500 mt-1">Your personal access token for API authentication</p>
23+
</div>
24+
25+
@if (isLoading()) {
26+
<div class="flex items-center justify-center py-4" data-testid="developer-token-loading">
27+
<i class="fa-light fa-circle-notch fa-spin text-2xl text-blue-400"></i>
28+
<span class="ml-3 text-gray-600">Loading token...</span>
29+
</div>
30+
} @else {
31+
<div class="space-y-4">
32+
<div class="overflow-x-auto">
33+
<table class="min-w-full divide-y divide-gray-200">
34+
<thead class="bg-gray-50">
35+
<tr>
36+
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Token</th>
37+
<th scope="col" class="relative px-6 py-3">
38+
<span class="sr-only">Actions</span>
39+
</th>
40+
</tr>
41+
</thead>
42+
<tbody class="bg-white divide-y divide-gray-200">
43+
<tr>
44+
<td class="px-6 py-4 whitespace-nowrap">
45+
<div class="flex items-center">
46+
<div class="flex-shrink-0">
47+
<i class="fa-light fa-key text-gray-400"></i>
48+
</div>
49+
<div class="ml-3">
50+
<div class="text-sm font-mono text-gray-900" data-testid="api-token-display">
51+
{{ maskToken() ? maskedToken() : apiToken() }}
52+
</div>
53+
<div class="text-xs text-gray-500">Personal Access Token</div>
54+
</div>
55+
</div>
56+
</td>
57+
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
58+
<button
59+
type="button"
60+
(click)="toggleTokenVisibility()"
61+
class="text-blue-600 hover:text-blue-800 transition-colors"
62+
[attr.data-testid]="'token-visibility-toggle'"
63+
[attr.aria-label]="maskToken() ? 'Show token' : 'Hide token'">
64+
<i [class]="maskToken() ? 'fa-light fa-eye' : 'fa-light fa-eye-slash'" class="text-sm"></i>
65+
</button>
66+
<button
67+
type="button"
68+
(click)="copyToken()"
69+
class="text-blue-600 hover:text-blue-800 transition-colors"
70+
data-testid="copy-token-button"
71+
aria-label="Copy token to clipboard">
72+
<i class="fa-light fa-copy text-sm"></i>
73+
</button>
74+
</td>
75+
</tr>
76+
</tbody>
77+
</table>
78+
</div>
79+
80+
<!-- Token Usage Guidelines -->
81+
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
82+
<div class="flex">
83+
<div class="flex-shrink-0">
84+
<i class="fa-light fa-exclamation-triangle text-amber-600"></i>
85+
</div>
86+
<div class="ml-3">
87+
<h4 class="text-sm font-medium text-amber-800">Security Guidelines</h4>
88+
<div class="mt-2 text-sm text-amber-700">
89+
<ul class="list-disc list-inside space-y-1">
90+
<li>This token is short-lived and automatically regenerated by the system</li>
91+
<li>Never commit this token to version control</li>
92+
</ul>
93+
</div>
94+
</div>
95+
</div>
96+
</div>
97+
</div>
98+
}
99+
</div>
100+
</lfx-card>
101+
</div>
102+
</div>
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { CommonModule } from '@angular/common';
5+
import { Component, computed, inject, OnInit, signal } from '@angular/core';
6+
import { CardComponent } from '@shared/components/card/card.component';
7+
import { MessageComponent } from '@shared/components/message/message.component';
8+
import { UserService } from '@shared/services/user.service';
9+
import { MessageService } from 'primeng/api';
10+
import { ToastModule } from 'primeng/toast';
11+
import { finalize } from 'rxjs';
12+
13+
@Component({
14+
selector: 'lfx-profile-developer',
15+
standalone: true,
16+
imports: [CommonModule, CardComponent, MessageComponent, ToastModule],
17+
providers: [MessageService],
18+
templateUrl: './profile-developer.component.html',
19+
})
20+
export class ProfileDeveloperComponent implements OnInit {
21+
private readonly userService = inject(UserService);
22+
private readonly messageService = inject(MessageService);
23+
24+
// State signals
25+
private readonly loadingSignal = signal<boolean>(false);
26+
private readonly apiTokenSignal = signal<string>('');
27+
28+
public readonly isLoading = computed(() => this.loadingSignal());
29+
public readonly apiToken = computed(() => this.apiTokenSignal());
30+
31+
// Token visibility toggle
32+
public maskToken = signal<boolean>(true);
33+
34+
// Computed masked token
35+
public readonly maskedToken = computed(() => {
36+
const token = this.apiToken();
37+
if (!token) return '';
38+
if (token.length <= 8) return '*'.repeat(token.length);
39+
// Show first 4 chars + fixed number of asterisks + last 4 chars for better UX
40+
return token.substring(0, 4) + '••••••••••••' + token.substring(token.length - 4);
41+
});
42+
43+
public ngOnInit(): void {
44+
this.loadTokenData();
45+
}
46+
47+
public toggleTokenVisibility(): void {
48+
this.maskToken.set(!this.maskToken());
49+
}
50+
51+
public async copyToken(): Promise<void> {
52+
const token = this.apiToken();
53+
if (!token) {
54+
this.messageService.add({
55+
severity: 'warn',
56+
summary: 'No Token',
57+
detail: 'No API token available to copy.',
58+
});
59+
return;
60+
}
61+
62+
try {
63+
await navigator.clipboard.writeText(token);
64+
this.messageService.add({
65+
severity: 'success',
66+
summary: 'Copied',
67+
detail: 'API token copied to clipboard successfully.',
68+
});
69+
} catch (error) {
70+
console.error('Failed to copy token:', error);
71+
this.messageService.add({
72+
severity: 'error',
73+
summary: 'Copy Failed',
74+
detail: 'Failed to copy token to clipboard. Please try again.',
75+
});
76+
}
77+
}
78+
79+
private loadTokenData(): void {
80+
this.loadingSignal.set(true);
81+
82+
this.userService
83+
.getDeveloperTokenInfo()
84+
.pipe(finalize(() => this.loadingSignal.set(false)))
85+
.subscribe({
86+
next: (tokenInfo) => {
87+
this.apiTokenSignal.set(tokenInfo.token);
88+
},
89+
error: (error) => {
90+
console.error('Failed to load developer token:', error);
91+
this.apiTokenSignal.set('Error loading token');
92+
this.messageService.add({
93+
severity: 'error',
94+
summary: 'Token Error',
95+
detail: 'Failed to load API token. Please refresh the page.',
96+
});
97+
},
98+
});
99+
}
100+
}

apps/lfx-one/src/app/modules/profile/profile.routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,8 @@ export const PROFILE_ROUTES: Routes = [
1616
path: 'email',
1717
loadComponent: () => import('./email/profile-email.component').then((m) => m.ProfileEmailComponent),
1818
},
19+
{
20+
path: 'developer',
21+
loadComponent: () => import('./developer/profile-developer.component').then((m) => m.ProfileDeveloperComponent),
22+
},
1923
];

apps/lfx-one/src/app/shared/components/header/header.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export class HeaderComponent {
5757
{
5858
label: 'Developer Settings',
5959
icon: 'fa-light fa-cog',
60-
target: '_blank',
60+
routerLink: '/profile/developer',
6161
},
6262
{
6363
separator: true,

apps/lfx-one/src/app/shared/services/user.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,11 @@ export class UserService {
123123
public getTwoFactorSettings(): Observable<TwoFactorSettings> {
124124
return this.http.get<TwoFactorSettings>('/api/profile/2fa-settings').pipe(take(1));
125125
}
126+
127+
/**
128+
* Get developer token information
129+
*/
130+
public getDeveloperTokenInfo(): Observable<{ token: string; type: string }> {
131+
return this.http.get<{ token: string; type: string }>('/api/profile/developer').pipe(take(1));
132+
}
126133
}

apps/lfx-one/src/server/controllers/profile.controller.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,4 +598,59 @@ export class ProfileController {
598598
next(error);
599599
}
600600
}
601+
602+
/**
603+
* GET /api/profile/developer - Get current user's developer token information
604+
*/
605+
public async getDeveloperTokenInfo(req: Request, res: Response, next: NextFunction): Promise<void> {
606+
const startTime = Logger.start(req, 'get_developer_token_info');
607+
608+
try {
609+
// Get user ID from auth context
610+
const userId = await getUsernameFromAuth(req);
611+
612+
if (!userId) {
613+
Logger.error(req, 'get_developer_token_info', startTime, new Error('User not authenticated or user ID not found'));
614+
615+
const validationError = ServiceValidationError.forField('user_id', 'User authentication required', {
616+
operation: 'get_developer_token_info',
617+
service: 'profile_controller',
618+
path: req.path,
619+
});
620+
621+
return next(validationError);
622+
}
623+
624+
// Extract the bearer token from the request (set by auth middleware)
625+
const bearerToken = req.bearerToken;
626+
627+
if (!bearerToken) {
628+
Logger.error(req, 'get_developer_token_info', startTime, new Error('No bearer token available'));
629+
630+
const validationError = ServiceValidationError.forField('token', 'No API token available for user', {
631+
operation: 'get_developer_token_info',
632+
service: 'profile_controller',
633+
path: req.path,
634+
});
635+
636+
return next(validationError);
637+
}
638+
639+
// Return token information
640+
const tokenInfo = {
641+
token: bearerToken,
642+
type: 'Bearer',
643+
};
644+
645+
Logger.success(req, 'get_developer_token_info', startTime, {
646+
user_id: userId,
647+
token_length: bearerToken.length,
648+
});
649+
650+
res.json(tokenInfo);
651+
} catch (error) {
652+
Logger.error(req, 'get_developer_token_info', startTime, error);
653+
next(error);
654+
}
655+
}
601656
}

apps/lfx-one/src/server/routes/profile.route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,7 @@ router.get('/email-preferences', (req, res, next) => profileController.getEmailP
4343
// PUT /api/profile/email-preferences - Update user email preferences
4444
router.put('/email-preferences', (req, res, next) => profileController.updateEmailPreferences(req, res, next));
4545

46+
// GET /api/profile/developer - Get current user's developer token information
47+
router.get('/developer', (req, res, next) => profileController.getDeveloperTokenInfo(req, res, next));
48+
4649
export default router;

0 commit comments

Comments
 (0)