feat(users): API Tokens tab wired to real endpoints (#36720) - #37084
feat(users): API Tokens tab wired to real endpoints (#36720)#37084AP2300 wants to merge 4 commits into
Conversation
Ships the Users portlet Create/Edit dialog shell with the Profile tab fully wired and placeholders for the three sibling tabs (delivered by #36718, #36719, #36720). Dialog / Profile tab: - 4-tab strip with Profile as the only functional tab; Roles, Permissions, and API Tokens render "Coming soon" placeholders - Header with avatar + name + Active status chip - Account section: first/last name, email, password + confirm, Active - Additional Info section: prefix/suffix/title/company/website - Access section: disabled (values informational only), shows admin / backend / frontend / hasConsoleAccess pulled from the loaded user - Delete User section (edit mode) with required replacement-user picker and email-typed confirmation List CRUD: - DotUsersService gains getUser/createUser/updateUser using POST/PUT/GET /api/v1/users; roles field intentionally omitted on update so backend preserves role membership (see UserResource#processRoles) - DotUsersListStore gains createUser / updateUser / deleteSingleUser - Bulk delete on the list toolbar now shows the same replacement picker instead of the old p-confirmDialog - /users route now resolves to the new users-beta portlet id Shared: - DotUsersReplacementPickerComponent — server-backed autocomplete used by both delete flows; excludes the users being deleted from suggestions client-side Test coverage: 56 tests in the portlet + 46 in data-access all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the API Tokens tab to the Create/Edit User dialog. Ships the visual shell — tokens table, Show inactive filter, Request New Token action, per-row Revoke — backed by mock data pending endpoint wiring against POST/GET/PUT `/api/v1/apitoken`. Included: - `dot-users-api-tokens-tab` — presentational table + toolbar - `dot-users-request-token-dialog` — sub-dialog for Label, Expires Date (default ~3 years), and Allow Network (CIDR) fields - Swaps the placeholder tabpanel from #36717 for the real component - Tab-specific i18n keys Follow-ups tracked: - Wire against real endpoints (see UserResource / ApiTokenResource research summary in the parent ticket) - Show the one-time raw JWT after creation with a copy affordance - Replace hardcoded requestedBy with `GET /api/v1/users/current` Test coverage: 56 portlet tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per design feedback (M. Rojas, thread ts 1786448773): the sub-dialog was 620px wide and left the three inputs stretched across the panel. Dropping to 500px lines it up with the Confirmation / Warning bucket in `libs/portlets/CLAUDE.md` and keeps the form fields comfortably sized without visual slack. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace mock state with `DotUsersService` methods for list, create,
reveal (fresh JWT per call), and revoke against `/api/v1/apitoken`.
- Row click on a valid token opens a reveal dialog that fetches a
new JWT via GET /jwt (backend re-mints each call — no exposure).
- Request New Token dialog now POSTs the form and returns
{ jwt, token }; the reveal dialog surfaces the JWT immediately.
- Revoke uses p-confirmDialog; row action buttons stopPropagation to
avoid triggering the row-click reveal.
- Revoked/expired rows are muted (text-surface-400 + opacity-60) and
swap the action button for a small "Revoked" pill.
- Empty state centered with a lighter, italic tone.
- Create-mode users see a hint instead of the table because tokens
can only be issued to an existing user id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| <span | ||
| class="inline-block rounded-md bg-surface-100 px-2 py-1 text-xs text-surface-400" | ||
| [attr.data-testid]="'users-api-tokens-revoked-pill-' + token.id"> | ||
| {{ 'users.dialog.tokens.revoked' | dm }} |
There was a problem hiding this comment.
statusOf distinguishes expired from revoked, but the template collapses both into the "Revoked" pill — a token that simply aged out shows up as Revoked, which isn't true. Worth a second branch (or deriving the label key from status) so expired rows say Expired; right now the 'expired' arm of TokenStatus never reaches the UI.
| @if (!isInactive) { | ||
| <button | ||
| type="button" | ||
| class="cursor-pointer border-0 bg-transparent p-0 text-sm font-medium text-red-500 hover:underline" |
There was a problem hiding this comment.
Hand-rolled <button> with a hardcoded text-red-500, while everything else in this file uses p-button — <p-button severity="danger" [text]="true" size="small"> gets the same look off the theme. Same for the pill just below: p-tag is what we use for read-only status elsewhere in the portlet, like the access chips in the list.
| <div | ||
| class="text-color-secondary flex items-center gap-2 text-sm" | ||
| data-testid="users-api-tokens-reveal-loading"> | ||
| <i class="pi pi-spinner pi-spin"></i> |
There was a problem hiding this comment.
pi pi-spinner is PrimeIcons — the rest of this portlet is on Material Symbols (material-symbols-rounded in the list and the profile tab). progress_activity with animate-spin matches.
| [class.text-surface-400]="isInactive" | ||
| [class.opacity-60]="isInactive" | ||
| [attr.title]="!isInactive ? ('users.dialog.tokens.row.reveal-hint' | dm) : null" | ||
| (click)="onRowClick(token)" |
There was a problem hiding this comment.
Revealing the JWT is the row's primary action but it's mouse-only — the <tr> isn't focusable and there's no key handler, with title as the only hint. Since the separate Reveal button was dropped in favour of the row click, this is now the only path to the JWT, so keyboard users have none.
| </ng-template> | ||
| <ng-template pTemplate="emptymessage"> | ||
| <tr class="hover:bg-transparent!"> | ||
| <td colspan="7" class="border-none p-0"> |
There was a problem hiding this comment.
A failed fetch leaves tokens empty, so a 500 renders as "No active tokens." and the toast is the only sign something broke. The copy is also off when Show inactive is checked and there really are no tokens — it isn't only active ones being listed at that point.
| /** | ||
| * Mints a fresh JWT for an existing token id. The token record | ||
| * itself is unchanged — the backend simply signs a new JWT value | ||
| * over the same token, which supersedes any previously-issued one. |
There was a problem hiding this comment.
generateApiToken only signs — it doesn't touch the record or invalidate anything, so every JWT previously minted for this token stays valid until it's revoked or expires. "Supersedes any previously-issued one" reads like rotation, which could leave someone thinking a re-reveal kills a leaked JWT. Revoke is the only thing that does.
| .get<{ entity: { jwt: string } }>( | ||
| `/api/v1/apitoken/${encodeURIComponent(tokenId)}/jwt` | ||
| ) | ||
| .pipe(map((response) => response.entity?.jwt ?? '')); |
There was a problem hiding this comment.
?? '' turns a malformed response into an empty string, and the reveal dialog treats '' as falsy — so the spinner sits there forever instead of surfacing anything. Letting it come through as null and handling it in the error path would at least fail visibly.
| * tokens only — the UI mirrors the guard so the button only shows | ||
| * for those rows. | ||
| */ | ||
| deleteApiToken(tokenId: string): Observable<unknown> { |
There was a problem hiding this comment.
deleteApiToken isn't called anywhere (only mocked in a spec), and the comment describes a guard the UI mirrors even though there's no Delete affordance — worth dropping until the button lands, or leaving a TODO.
While you're in here: dot-users.service.spec.ts covers the filter and delete-user URLs but none of the five new token methods. The showRevoked param and the two response-envelope unwraps would be cheap to pin.
| DotMessagePipe | ||
| ], | ||
| templateUrl: './dot-users-request-token-dialog.component.html', | ||
| changeDetection: ChangeDetectionStrategy.OnPush |
There was a problem hiding this comment.
Same decorator/naming notes as the tab (changeDetection, standalone, $ on the signals, # on the deps). No spec for this dialog either — the required-field validation, the past guard and the close-with-result path are the interesting bits.
| }); | ||
| } | ||
|
|
||
| private expirationSecondsFrom(expiresIsoDate: string): number { |
There was a problem hiding this comment.
defaultExpiryDate emits YYYY-MM-DD, and new Date('2029-08-19') parses as UTC midnight while Date.now() is local — so the TTL is off by the browser's offset and the token expires at UTC midnight of the chosen date, which is the previous evening for anyone west of UTC. It also means picking today's date gets rejected as past in those timezones. Harmless at the three-year default; building the Date from the parts keeps it local.
Closes #36720
Depends on #36990 (Profile tab shell). This PR's diff currently includes the shell commits; once #36990 is merged to main, this branch will be rebased onto the new main and the diff will show only the API Tokens tab changes.
Summary
Ships the API Tokens tab wired against the modern
ApiTokenResource(/api/v1/apitoken/*) — visual and behavioral parity with the legacy admin's token surface.GET /api/v1/apitoken/{userId}/tokens?showRevoked={bool}with a "Show revoked" toggle that mirrors the legacy checkbox./api/v1/apitokenand returns{jwt, token}.GET /api/v1/apitoken/{id}/jwt. Backend mints a fresh signed JWT on every call ("Generates a new JWT for an existing token" / "overwrites the JWT value"), so re-revealing is safe by design — no exposure risk from a persisted token value.PUT /api/v1/apitoken/{id}/revokebehind ap-confirmDialog. Row stays in the list for audit.text-surface-400 opacity-60), non-clickable, action cell shows a smallRevokedpill instead of a button.Notable non-obvious calls
Test plan
🤖 Generated with Claude Code