Skip to content

Commit e7728f7

Browse files
jordaneclaudeasithade
authored
feat(profile): add developer settings page with API token display (#105)
* 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> * feat(profile): add developer settings menu and refactor component - Add Developer Settings menu item to profile layout navigation with purple icon - Refactor developer component to follow established patterns (toSignal, initialize methods) - Update developer component styling to match password/email settings layout - Replace table layout with card-based design for consistency - Implement Angular CDK Clipboard service for token copying - Improve responsive design and accessibility Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva <asithade@gmail.com> * feat(security): add no-store cache headers to developer token endpoint - Add comprehensive cache-control headers to prevent caching of bearer tokens - Implement no-store, no-cache, must-revalidate, and private directives - Include legacy Pragma and Expires headers for broader compatibility - Prevent intermediary proxies and browser caches from storing sensitive tokens Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Asitha de Silva <asithade@gmail.com> * fix(test): add button should not be visible when user is not writer Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Jordan Evans <jevans@linuxfoundation.org> Signed-off-by: Asitha de Silva <asithade@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Asitha de Silva <asithade@gmail.com>
1 parent 56c1599 commit e7728f7

File tree

9 files changed

+265
-3
lines changed

9 files changed

+265
-3
lines changed

apps/lfx-one/src/app/layouts/profile-layout/profile-layout.component.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ export class ProfileLayoutComponent {
102102
routerLink: '/profile/email',
103103
routerLinkActiveOptions: { exact: true },
104104
},
105+
{
106+
label: 'Developer Settings',
107+
icon: 'fa-light fa-code text-purple-500',
108+
routerLink: '/profile/developer',
109+
routerLinkActiveOptions: { exact: true },
110+
},
105111
]);
106112
}
107113

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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">Your API token provides programmatic access to LFX services. Keep this token secure and never share it publicly.</p>
10+
</div>
11+
</ng-template>
12+
</lfx-message>
13+
14+
<div class="flex flex-col gap-6">
15+
<!-- API Token Section -->
16+
<lfx-card>
17+
<div class="flex flex-col gap-6">
18+
<div>
19+
<h3 class="text-base font-medium text-gray-900" data-testid="developer-token-heading">API Token</h3>
20+
<p class="text-sm text-gray-500 mt-1">Your personal access token for API authentication</p>
21+
</div>
22+
23+
@if (isLoading()) {
24+
<div class="flex items-center justify-center py-4" data-testid="developer-token-loading">
25+
<i class="fa-light fa-circle-notch fa-spin text-2xl text-blue-400"></i>
26+
<span class="ml-3 text-gray-600">Loading token...</span>
27+
</div>
28+
} @else {
29+
<div class="space-y-4">
30+
<!-- Token Display Section -->
31+
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg gap-3" data-testid="api-token-container">
32+
<div class="flex items-center space-x-4 flex-1">
33+
<div class="flex-shrink-0">
34+
<i class="fa-light fa-key text-purple-500 text-xl"></i>
35+
</div>
36+
<div class="flex-1 min-w-0">
37+
<div class="flex items-center space-x-2">
38+
<span class="text-sm font-medium text-gray-900">Personal Access Token</span>
39+
</div>
40+
<div class="text-sm font-mono text-gray-600 mt-1 truncate break-all whitespace-pre-wrap" data-testid="api-token-display">
41+
{{ maskToken() ? maskedToken() : apiToken() }}
42+
</div>
43+
</div>
44+
</div>
45+
46+
<!-- Action Buttons -->
47+
<div class="flex items-center gap-2">
48+
<lfx-button
49+
type="button"
50+
size="small"
51+
[icon]="maskToken() ? 'fa-light fa-eye' : 'fa-light fa-eye-slash'"
52+
severity="secondary"
53+
label="Show"
54+
(onClick)="toggleTokenVisibility()"
55+
[attr.data-testid]="'token-visibility-toggle'">
56+
</lfx-button>
57+
<lfx-button
58+
type="button"
59+
size="small"
60+
icon="fa-light fa-copy"
61+
label="Copy"
62+
severity="primary"
63+
(onClick)="copyToken()"
64+
data-testid="copy-token-button">
65+
</lfx-button>
66+
</div>
67+
</div>
68+
69+
<!-- Token Usage Guidelines -->
70+
<div class="bg-amber-50 border border-amber-200 rounded-lg p-4">
71+
<div class="flex">
72+
<div class="flex-shrink-0">
73+
<i class="fa-light fa-exclamation-triangle text-amber-600"></i>
74+
</div>
75+
<div class="ml-3">
76+
<h4 class="text-sm font-medium text-amber-800">Security Guidelines</h4>
77+
<div class="mt-2 text-sm text-amber-700">
78+
<ul class="list-disc list-inside space-y-1">
79+
<li>This token is short-lived and automatically regenerated by the system</li>
80+
<li>Never commit this token to version control</li>
81+
</ul>
82+
</div>
83+
</div>
84+
</div>
85+
</div>
86+
</div>
87+
}
88+
</div>
89+
</lfx-card>
90+
</div>
91+
</div>
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { Clipboard } from '@angular/cdk/clipboard';
5+
import { CommonModule } from '@angular/common';
6+
import { Component, computed, inject, Signal, signal } from '@angular/core';
7+
import { toSignal } from '@angular/core/rxjs-interop';
8+
import { ButtonComponent } from '@shared/components/button/button.component';
9+
import { CardComponent } from '@shared/components/card/card.component';
10+
import { MessageComponent } from '@shared/components/message/message.component';
11+
import { UserService } from '@shared/services/user.service';
12+
import { MessageService } from 'primeng/api';
13+
import { ToastModule } from 'primeng/toast';
14+
import { finalize } from 'rxjs';
15+
16+
@Component({
17+
selector: 'lfx-profile-developer',
18+
standalone: true,
19+
imports: [CommonModule, ButtonComponent, CardComponent, MessageComponent, ToastModule],
20+
providers: [],
21+
templateUrl: './profile-developer.component.html',
22+
})
23+
export class ProfileDeveloperComponent {
24+
private readonly userService = inject(UserService);
25+
private readonly messageService = inject(MessageService);
26+
private readonly clipboard = inject(Clipboard);
27+
28+
// Loading state
29+
public loading = signal<boolean>(true);
30+
31+
// Token data using toSignal pattern
32+
public tokenInfo = this.initializeTokenInfo();
33+
34+
// Loading state computed from tokenInfo
35+
public readonly isLoading = computed(() => this.loading());
36+
37+
// API token computed from tokenInfo
38+
public readonly apiToken = computed(() => this.tokenInfo()?.token || '');
39+
40+
// Token visibility toggle
41+
public maskToken = signal<boolean>(true);
42+
43+
// Computed masked token
44+
public readonly maskedToken = computed(() => {
45+
const token = this.apiToken();
46+
if (!token) return '';
47+
if (token.length <= 8) return '*'.repeat(token.length);
48+
// Show first 4 chars + fixed number of asterisks + last 4 chars for better UX
49+
return token.substring(0, 4) + '••••••••••••' + token.substring(token.length - 4);
50+
});
51+
52+
public toggleTokenVisibility(): void {
53+
this.maskToken.set(!this.maskToken());
54+
}
55+
56+
public copyToken(): void {
57+
const token = this.apiToken();
58+
if (!token) {
59+
this.messageService.add({
60+
severity: 'warn',
61+
summary: 'No Token',
62+
detail: 'No API token available to copy.',
63+
});
64+
return;
65+
}
66+
67+
const success = this.clipboard.copy(token);
68+
if (success) {
69+
this.messageService.add({
70+
severity: 'success',
71+
summary: 'Copied',
72+
detail: 'API token copied to clipboard successfully.',
73+
});
74+
} else {
75+
this.messageService.add({
76+
severity: 'error',
77+
summary: 'Copy Failed',
78+
detail: 'Failed to copy token to clipboard. Please try again.',
79+
});
80+
}
81+
}
82+
83+
private initializeTokenInfo(): Signal<{ token: string; type: string } | null> {
84+
this.loading.set(true);
85+
return toSignal(this.userService.getDeveloperTokenInfo().pipe(finalize(() => this.loading.set(false))), { initialValue: null });
86+
}
87+
}

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/modules/project/dashboard/project-dashboard/project.component.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,10 @@ <h3 class="text-sm font-display text-gray-500">Recently Updated Committees</h3>
258258
<i class="fa-light fa-people-group text-4xl text-gray-400 mb-4"></i>
259259
<h3 class="text-lg font-medium text-gray-900 mb-2">No Committees</h3>
260260
<p class="text-gray-600 mb-4 text-sm">This project doesn't have any committees yet.</p>
261-
<lfx-button label="Add Committee" icon="fa-light fa-people-group" severity="secondary" size="small" (onClick)="openCreateDialog()">
262-
</lfx-button>
261+
@if (project()?.writer) {
262+
<lfx-button label="Add Committee" icon="fa-light fa-people-group" severity="secondary" size="small" (onClick)="openCreateDialog()">
263+
</lfx-button>
264+
}
263265
</div>
264266
</div>
265267
}

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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,4 +598,66 @@ 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+
// Set cache headers to prevent caching of sensitive bearer tokens
651+
res.set({
652+
['Cache-Control']: 'no-store, no-cache, must-revalidate, private',
653+
Pragma: 'no-cache',
654+
Expires: '0',
655+
});
656+
657+
res.json(tokenInfo);
658+
} catch (error) {
659+
Logger.error(req, 'get_developer_token_info', startTime, error);
660+
next(error);
661+
}
662+
}
601663
}

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)