Skip to content

feat(users): API Tokens tab wired to real endpoints (#36720) - #37084

Open
AP2300 wants to merge 4 commits into
mainfrom
issue-36720-users-portlet-api-tokens-tab
Open

feat(users): API Tokens tab wired to real endpoints (#36720)#37084
AP2300 wants to merge 4 commits into
mainfrom
issue-36720-users-portlet-api-tokens-tab

Conversation

@AP2300

@AP2300 AP2300 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

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.

  • ListGET /api/v1/apitoken/{userId}/tokens?showRevoked={bool} with a "Show revoked" toggle that mirrors the legacy checkbox.
  • Create — a small sub-dialog collects Label + Expires Date + Allow Network, converts the date to `expirationSeconds`, POSTs to /api/v1/apitoken and returns {jwt, token}.
  • Reveal — clicking a valid row opens a modal that calls 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.
  • RevokePUT /api/v1/apitoken/{id}/revoke behind a p-confirmDialog. Row stays in the list for audit.
  • Revoked / expired rows — muted (text-surface-400 opacity-60), non-clickable, action cell shows a small Revoked pill instead of a button.

Notable non-obvious calls

  • Dropped legacy "Get Token" separate button. A single click on the row does the same thing.
  • Dropped delete affordance for now — revoked rows show a static "Revoked" indicator only. The DELETE endpoint still exists; we can add a Delete button later if audits build up.
  • Create-mode users see a hint, not the table. A userId is required to key any of the token endpoints.

Test plan

  • Open an existing user with tokens — table lists them; toggling "Show revoked" refetches
  • Click a valid row → reveal dialog opens, JWT lands, Copy button works
  • Close and re-open the reveal → a fresh JWT string arrives (different signature)
  • Request a new token → dialog closes, list refreshes with the new row, reveal dialog opens with the returned JWT
  • Revoke a valid token → confirmation modal → on accept the row flips to muted "Revoked", becomes non-clickable
  • Open the dialog in create mode → tab body renders the "save the user first" hint

🤖 Generated with Claude Code

AP2300 and others added 4 commits August 10, 2026 16:35
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>
@github-actions github-actions Bot mentioned this pull request Aug 17, 2026
9 tasks
@github-actions github-actions Bot added the Area : Frontend PR changes Angular/TypeScript frontend code label Aug 17, 2026
<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 }}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ?? ''));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?? '' 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> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Users portlet: API Tokens tab

2 participants