Skip to content

Commit 9d2b03d

Browse files
committed
feat(portal): Implement Personal Access Token Management UI
The API Tokens modal was a non-functional stub — list/create/revoke/ delete all had commented-out or empty handler bodies, so no button in the UI actually did anything even though the backend was fully built. Wires the component up to the real PAT REST endpoints via the generated UserService (list/create/update/delete/refresh), adds expiry-in-days and per-project pull/push scope selection to the create form (auto-computed full-access scope when everything's selected, explicit empty scope when nothing is), a refresh-secret flow with its own reveal-once modal, and a delete confirmation dialog (new ConfirmationTargets.USER_PAT). The create form's submit button is associated with the form via form="apiTokenForm" so it actually submits despite living in the modal footer outside the <form> tag, and both the name/description inputs and scope checkboxes disable once a secret is on screen so the visible values can't drift from what was actually created. Copy-to-clipboard uses navigator.clipboard.writeText with a document.execCommand fallback for browsers without the Clipboard API, rather than only the deprecated execCommand path. Signed-off-by: Ross Golder <ross@golder.org>
1 parent b616684 commit 9d2b03d

4 files changed

Lines changed: 478 additions & 51 deletions

File tree

‎src/portal/src/app/base/account-settings/api-tokens-modal.component.html‎

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ <h3 class="modal-title" i18n="@@PROFILE.API_TOKENS">API Tokens</h3>
6262
{{ 'PROFILE.TOKEN_NEVER' | translate }}
6363
</span>
6464
<span *ngIf="token.expires_at !== -1">
65-
{{ token.expires_at | date : 'short' }}
65+
{{ token.expires_at * 1000 | date : 'short' }}
6666
</span>
6767
</clr-dg-cell>
6868
<clr-dg-cell>
@@ -89,6 +89,13 @@ <h3 class="modal-title" i18n="@@PROFILE.API_TOKENS">API Tokens</h3>
8989
*ngIf="!token.disabled">
9090
{{ 'PROFILE.REVOKE_TOKEN' | translate }}
9191
</button>
92+
<button
93+
clrDropdownItem
94+
*ngIf="!token.disabled && !token.expired"
95+
[disabled]="refreshingTokenId !== null"
96+
(click)="refreshTokenSecret(token.id)">
97+
{{ 'PROFILE.REFRESH_TOKEN' | translate }}
98+
</button>
9299
<button
93100
clrDropdownItem
94101
(click)="deleteToken(token.id)">
@@ -145,9 +152,97 @@ <h3 class="modal-title" i18n="@@PROFILE.CREATE_TOKEN">Create API Token</h3>
145152
clrTextarea
146153
placeholder="Enter token description (optional)"
147154
[(ngModel)]="newTokenForm.description"
155+
[disabled]="!!createdTokenSecret"
148156
name="token_description"></textarea>
149157
</clr-textarea-container>
150158

159+
<clr-number-input-container>
160+
<label>{{ 'PROFILE.PAT_EXPIRES_IN_DAYS' | translate }}</label>
161+
<input
162+
clrNumberInput
163+
name="token_expires"
164+
[(ngModel)]="newTokenForm.expiresInDays"
165+
[disabled]="!!createdTokenSecret"
166+
min="0" />
167+
<clr-control-helper>{{
168+
'PROFILE.PAT_EXPIRES_PLACEHOLDER' | translate
169+
}}</clr-control-helper>
170+
</clr-number-input-container>
171+
172+
<div class="scope-section" style="margin-top: 16px">
173+
<label style="font-weight: 600; display: block">
174+
{{ 'PROFILE.PAT_SCOPE' | translate }}
175+
</label>
176+
<p style="font-size: 13px; color: #666; margin-bottom: 8px">
177+
{{ 'PROFILE.PAT_SCOPE_HELP' | translate }}
178+
</p>
179+
<div *ngIf="scopeLoading">
180+
<span class="spinner spinner-sm"></span>
181+
{{ 'PROFILE.LOADING' | translate }}
182+
</div>
183+
<div *ngIf="!scopeLoading && scopeProjects.length > 0">
184+
<div style="margin-bottom: 8px">
185+
<button
186+
type="button"
187+
class="btn btn-sm btn-link"
188+
[disabled]="!!createdTokenSecret"
189+
(click)="selectAllScope()">
190+
{{ 'PROFILE.PAT_SCOPE_SELECT_ALL' | translate }}
191+
</button>
192+
<button
193+
type="button"
194+
class="btn btn-sm btn-link"
195+
[disabled]="!!createdTokenSecret"
196+
(click)="deselectAllScope()">
197+
{{ 'PROFILE.PAT_SCOPE_DESELECT_ALL' | translate }}
198+
</button>
199+
</div>
200+
<table class="table table-noborder" style="width: 100%">
201+
<thead>
202+
<tr>
203+
<th>
204+
{{
205+
'PROFILE.PAT_SCOPE_PROJECT' | translate
206+
}}
207+
</th>
208+
<th style="width: 80px; text-align: center">
209+
{{ 'PROFILE.PAT_SCOPE_PULL' | translate }}
210+
</th>
211+
<th style="width: 80px; text-align: center">
212+
{{ 'PROFILE.PAT_SCOPE_PUSH' | translate }}
213+
</th>
214+
</tr>
215+
</thead>
216+
<tbody>
217+
<tr *ngFor="let p of scopeProjects">
218+
<td>{{ p.project_name }}</td>
219+
<td style="text-align: center">
220+
<input
221+
clrCheckbox
222+
type="checkbox"
223+
[(ngModel)]="p.pull"
224+
[disabled]="!!createdTokenSecret"
225+
[name]="'pull_' + p.project_id" />
226+
</td>
227+
<td style="text-align: center">
228+
<input
229+
clrCheckbox
230+
type="checkbox"
231+
[(ngModel)]="p.push"
232+
[disabled]="!!createdTokenSecret"
233+
[name]="'push_' + p.project_id" />
234+
</td>
235+
</tr>
236+
</tbody>
237+
</table>
238+
</div>
239+
<div
240+
*ngIf="!scopeLoading && scopeProjects.length === 0"
241+
style="font-size: 13px; color: #666">
242+
{{ 'PROFILE.PAT_SCOPE_NO_PROJECTS' | translate }}
243+
</div>
244+
</div>
245+
151246
<div *ngIf="createdTokenSecret" class="alert alert-info">
152247
<div class="alert-items">
153248
<div class="alert-item static">
@@ -214,3 +309,60 @@ <h3 class="modal-title" i18n="@@PROFILE.CREATE_TOKEN">Create API Token</h3>
214309
</button>
215310
</div>
216311
</clr-modal>
312+
313+
<!-- Refreshed Secret Modal -->
314+
<clr-modal
315+
[(clrModalOpen)]="showRefreshedSecretModal"
316+
[clrModalStaticBackdrop]="true"
317+
id="refreshed-secret-modal">
318+
<h3 class="modal-title">
319+
{{ 'PROFILE.REFRESH_TOKEN' | translate }}
320+
</h3>
321+
322+
<div class="modal-body">
323+
<div class="alert alert-info">
324+
<div class="alert-items">
325+
<div class="alert-item static">
326+
<div class="alert-icon-wrapper">
327+
<clr-icon
328+
class="alert-icon"
329+
shape="info-circle"></clr-icon>
330+
</div>
331+
<span class="alert-text">
332+
{{ 'PROFILE.PAT_SECRET_COPY_WARNING' | translate }}
333+
</span>
334+
</div>
335+
</div>
336+
</div>
337+
338+
<div class="form-group">
339+
<label>{{ 'PROFILE.TOKEN_SECRET' | translate }}</label>
340+
<div class="input-group">
341+
<input
342+
type="text"
343+
class="form-control"
344+
[value]="refreshedTokenSecret"
345+
readonly />
346+
<button
347+
type="button"
348+
class="btn btn-primary"
349+
(click)="copyRefreshedTokenSecret()">
350+
{{ 'BUTTON.COPY' | translate }}
351+
</button>
352+
</div>
353+
</div>
354+
</div>
355+
356+
<div class="modal-footer">
357+
<button
358+
type="button"
359+
class="btn btn-primary"
360+
(click)="closeRefreshedSecretModal()">
361+
{{ 'BUTTON.CLOSE' | translate }}
362+
</button>
363+
</div>
364+
</clr-modal>
365+
366+
<confirmation-dialog
367+
#confirmationDialog
368+
(confirmAction)="confirmDeleteToken($event)"></confirmation-dialog>

0 commit comments

Comments
 (0)