From dddb07ab4f1becfca37742d5c80b8dd655474471 Mon Sep 17 00:00:00 2001 From: s3inlc Date: Tue, 11 Aug 2026 12:15:47 +0200 Subject: [PATCH] added tokenName as attribute and allow setting and renaming it --- .../api-key-detail.component.html | 5 ++ .../new-api-key/new-api-key.component.html | 7 ++ .../new-api-key/new-api-key.component.ts | 3 +- .../api-keys/new-api-key/new-api-key.form.ts | 2 + .../api-tokens-table.component.html | 1 + .../api-tokens-table.component.ts | 66 ++++++++++++++++++- .../api-tokens-table.constants.ts | 16 +++-- src/app/core/_models/api-token.model.ts | 1 + src/generated/api/types/api-token.ts | 5 ++ src/generated/api/zod/api-token.ts | 9 ++- 10 files changed, 105 insertions(+), 10 deletions(-) diff --git a/src/app/account/api-keys/api-key-detail/api-key-detail.component.html b/src/app/account/api-keys/api-key-detail/api-key-detail.component.html index 04ef2c130..7f42a9eae 100644 --- a/src/app/account/api-keys/api-key-detail/api-key-detail.component.html +++ b/src/app/account/api-keys/api-key-detail/api-key-detail.component.html @@ -23,6 +23,11 @@
+ "Valid Until" must be on or after "Valid From". } + +
Scopes @if (loadingScopes) { diff --git a/src/app/account/api-keys/new-api-key/new-api-key.component.ts b/src/app/account/api-keys/new-api-key/new-api-key.component.ts index 971726bb2..cb01c75c6 100644 --- a/src/app/account/api-keys/new-api-key/new-api-key.component.ts +++ b/src/app/account/api-keys/new-api-key/new-api-key.component.ts @@ -86,7 +86,7 @@ export class NewApiKeyComponent implements OnInit { return; } - const { validFrom, validUntil, scopes } = this.form.getRawValue(); + const { tokenName, validFrom, validUntil, scopes } = this.form.getRawValue(); if (!validFrom || !validUntil) { this.alert.showErrorMessage('Please select a valid date range.'); return; @@ -95,6 +95,7 @@ export class NewApiKeyComponent implements OnInit { this.submitting = true; try { const payload = { + tokenName: tokenName.trim(), scopes, startValid: unixTimestampFromDate(startOfDay(validFrom)), // endValid is an *exclusive* cutoff: the token is valid through the diff --git a/src/app/account/api-keys/new-api-key/new-api-key.form.ts b/src/app/account/api-keys/new-api-key/new-api-key.form.ts index c6e8df24c..3e63289bd 100644 --- a/src/app/account/api-keys/new-api-key/new-api-key.form.ts +++ b/src/app/account/api-keys/new-api-key/new-api-key.form.ts @@ -11,6 +11,7 @@ import { endOfDay, startOfDay } from '@src/app/shared/utils/datetime'; * `scopes` is an array of permission keys from the `Perm` enum tree (e.g. `permTaskRead`). */ export interface NewApiKeyForm { + tokenName: FormControl; validFrom: FormControl; validUntil: FormControl; scopes: FormControl; @@ -36,6 +37,7 @@ export const getNewApiKeyForm = (validityDays: number): FormGroup return new FormGroup( { + tokenName: new FormControl('', { nonNullable: true }), validFrom: new FormControl(validFrom, [Validators.required]), validUntil: new FormControl(validUntil, [Validators.required]), scopes: new FormControl([], { nonNullable: true, validators: [Validators.required] }) diff --git a/src/app/core/_components/tables/api-tokens-table/api-tokens-table.component.html b/src/app/core/_components/tables/api-tokens-table/api-tokens-table.component.html index dcd09142c..bb6b644a1 100644 --- a/src/app/core/_components/tables/api-tokens-table/api-tokens-table.component.html +++ b/src/app/core/_components/tables/api-tokens-table/api-tokens-table.component.html @@ -10,4 +10,5 @@ [isFilterable]="false" [isPageable]="true" (rowActionClicked)="rowActionClicked($event)" + (editableSaved)="editableSaved($event)" /> diff --git a/src/app/core/_components/tables/api-tokens-table/api-tokens-table.component.ts b/src/app/core/_components/tables/api-tokens-table/api-tokens-table.component.ts index 9cc414a76..cc708de4f 100644 --- a/src/app/core/_components/tables/api-tokens-table/api-tokens-table.component.ts +++ b/src/app/core/_components/tables/api-tokens-table/api-tokens-table.component.ts @@ -1,23 +1,30 @@ import { Observable, catchError, firstValueFrom, of } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; -import { AfterViewInit, Component, OnDestroy, OnInit } from '@angular/core'; +import { AfterViewInit, Component, OnDestroy, OnInit, inject } from '@angular/core'; import { SafeHtml } from '@angular/platform-browser'; import { ApiTokenStatus, JApiToken, computeApiTokenStatus } from '@models/api-token.model'; import { ApiTokensContextMenuService } from '@services/context-menu/users/api-tokens-menu.service'; import { SERV } from '@services/main.config'; +import { ApiTokensRoleService } from '@services/roles/user/api-tokens-role.service'; import { ActionMenuEvent } from '@components/menus/action-menu/action-menu.model'; import { RowActionMenuAction } from '@components/menus/row-action-menu/row-action-menu.constants'; import { ApiTokensRowAction, ApiTokensTableCol, - ApiTokensTableColumnLabel + ApiTokensTableColumnLabel, + ApiTokensTableEditableAction } from '@components/tables/api-tokens-table/api-tokens-table.constants'; import { BaseTableComponent } from '@components/tables/base-table/base-table.component'; -import { HTTableColumn, HTTableIcon, HTTableRouterLink } from '@components/tables/ht-table/ht-table.models'; +import { + HTTableColumn, + HTTableEditable, + HTTableIcon, + HTTableRouterLink +} from '@components/tables/ht-table/ht-table.models'; import { TableDialogComponent } from '@components/tables/table-dialog/table-dialog.component'; import { DialogData } from '@components/tables/table-dialog/table-dialog.model'; @@ -31,6 +38,7 @@ import { formatUnixTimestamp, lastValidSecond } from '@src/app/shared/utils/date standalone: false }) export class ApiTokensTableComponent extends BaseTableComponent implements OnInit, OnDestroy, AfterViewInit { + private readonly apiTokensRoleService = inject(ApiTokensRoleService); tableColumns: HTTableColumn[] = []; dataSource: ApiTokensDataSource; @@ -54,6 +62,25 @@ export class ApiTokensTableComponent extends BaseTableComponent implements OnIni } getColumns(): HTTableColumn[] { + const tokenNameColumn: HTTableColumn = { + id: ApiTokensTableCol.TOKEN_NAME, + dataKey: 'tokenName', + isSortable: true, + export: async (token: JApiToken) => token.tokenName?.trim() ?? '' + }; + + if (this.apiTokensRoleService.hasRole('update')) { + tokenNameColumn.editable = (token: JApiToken) => { + return { + data: token, + value: token.tokenName ?? '', + action: ApiTokensTableEditableAction.CHANGE_TOKEN_NAME + }; + }; + } else { + tokenNameColumn.render = (token: JApiToken) => token.tokenName?.trim() || '—'; + } + return [ { id: ApiTokensTableCol.ID, @@ -62,6 +89,7 @@ export class ApiTokensTableComponent extends BaseTableComponent implements OnIni routerLink: (token: JApiToken) => this.renderDetailLink(token), export: async (token: JApiToken) => token.id + '' }, + tokenNameColumn, { id: ApiTokensTableCol.VALID_FROM, dataKey: 'startValid', @@ -127,6 +155,38 @@ export class ApiTokensTableComponent extends BaseTableComponent implements OnIni return status.charAt(0).toUpperCase() + status.slice(1); } + // --- Inline editing --- + + editableSaved(editable: HTTableEditable): void { + switch (editable.action) { + case ApiTokensTableEditableAction.CHANGE_TOKEN_NAME: + void this.changeTokenName(editable.data, editable.value); + break; + } + } + + private async changeTokenName(token: JApiToken, value: string): Promise { + const newName = value.trim(); + if ((token.tokenName ?? '') === newName) { + this.alertService.showInfoMessage('Nothing changed'); + return; + } + + try { + await firstValueFrom( + this.gs.update(SERV.API_TOKENS, token.id, { tokenName: newName }).pipe( + catchError((error: HttpErrorResponse) => { + throw error; + }) + ) + ); + this.alertService.showSuccessMessage(`Renamed API key #${token.id} to "${newName || '(empty)'}".`); + this.reload(); + } catch (error) { + this.alertService.showErrorMessage(`Could not rename API key: ${this.extractMessage(error)}`); + } + } + // --- Action handling --- rowActionClicked(event: ActionMenuEvent): void { diff --git a/src/app/core/_components/tables/api-tokens-table/api-tokens-table.constants.ts b/src/app/core/_components/tables/api-tokens-table/api-tokens-table.constants.ts index 473cd334d..b875a5f5f 100644 --- a/src/app/core/_components/tables/api-tokens-table/api-tokens-table.constants.ts +++ b/src/app/core/_components/tables/api-tokens-table/api-tokens-table.constants.ts @@ -1,14 +1,16 @@ export const ApiTokensTableCol = { ID: 0, - VALID_FROM: 1, - VALID_UNTIL: 2, - STATUS: 3, - CREATOR: 4 + TOKEN_NAME: 1, + VALID_FROM: 2, + VALID_UNTIL: 3, + STATUS: 4, + CREATOR: 5 } as const; export type ApiTokensTableCol = (typeof ApiTokensTableCol)[keyof typeof ApiTokensTableCol]; export const ApiTokensTableColumnLabel: Record = { [ApiTokensTableCol.ID]: 'ID', + [ApiTokensTableCol.TOKEN_NAME]: 'Token Name', [ApiTokensTableCol.VALID_FROM]: 'Valid From', [ApiTokensTableCol.VALID_UNTIL]: 'Expires At', [ApiTokensTableCol.STATUS]: 'Status', @@ -20,3 +22,9 @@ export type ApiTokensRowAction = (typeof ApiTokensRowAction)[keyof typeof ApiTok export const ApiTokensRowActionLabel = { REVOKE: 'Revoke API Key' } as const; export const ApiTokensRowActionIcon = { REVOKE: 'block' } as const; + +export const ApiTokensTableEditableAction = { + CHANGE_TOKEN_NAME: 'change-token-name' +} as const; +export type ApiTokensTableEditableAction = + (typeof ApiTokensTableEditableAction)[keyof typeof ApiTokensTableEditableAction]; diff --git a/src/app/core/_models/api-token.model.ts b/src/app/core/_models/api-token.model.ts index d5175c762..b5b651565 100644 --- a/src/app/core/_models/api-token.model.ts +++ b/src/app/core/_models/api-token.model.ts @@ -20,6 +20,7 @@ export interface JApiToken extends BaseModel { endValid: number; userId: UserId; isRevoked: boolean; + tokenName?: string | undefined; token?: string | undefined; user?: JUser | null; } diff --git a/src/generated/api/types/api-token.ts b/src/generated/api/types/api-token.ts index ca14e8010..a7f5c5aff 100644 --- a/src/generated/api/types/api-token.ts +++ b/src/generated/api/types/api-token.ts @@ -9,6 +9,7 @@ export type ApiTokenCreate = { endValid: number; userId: number; isRevoked: boolean; + tokenName?: string; }; }; }; @@ -18,6 +19,7 @@ export type ApiTokenPatch = { type: 'apiToken'; attributes: { isRevoked?: boolean; + tokenName?: string; }; }; }; @@ -42,6 +44,7 @@ export type ApiTokenResponse = { endValid: number; userId: number; isRevoked: boolean; + tokenName?: string; token?: string; }; }; @@ -91,6 +94,7 @@ export type ApiTokenPostPatchResponse = { endValid: number; userId: number; isRevoked: boolean; + tokenName?: string; token?: string; }; }; @@ -116,6 +120,7 @@ export type ApiTokenListResponse = { endValid: number; userId: number; isRevoked: boolean; + tokenName?: string; token?: string; }; }>; diff --git a/src/generated/api/zod/api-token.ts b/src/generated/api/zod/api-token.ts index bfc3bcefa..0f6940ce5 100644 --- a/src/generated/api/zod/api-token.ts +++ b/src/generated/api/zod/api-token.ts @@ -8,7 +8,8 @@ export const zApiTokenCreate = z.object({ startValid: z.number(), endValid: z.number(), userId: z.int(), - isRevoked: z.boolean() + isRevoked: z.boolean(), + tokenName: z.string().optional() }) }) }); @@ -17,7 +18,8 @@ export const zApiTokenPatch = z.object({ data: z.object({ type: z.literal('apiToken'), attributes: z.object({ - isRevoked: z.boolean().optional() + isRevoked: z.boolean().optional(), + tokenName: z.string().optional() }) }) }); @@ -44,6 +46,7 @@ export const zApiTokenResponse = z.object({ endValid: z.number(), userId: z.int(), isRevoked: z.boolean(), + tokenName: z.string().optional(), token: z.string().optional() }) }), @@ -101,6 +104,7 @@ export const zApiTokenPostPatchResponse = z.object({ endValid: z.number(), userId: z.int(), isRevoked: z.boolean(), + tokenName: z.string().optional(), token: z.string().optional() }) }) @@ -129,6 +133,7 @@ export const zApiTokenListResponse = z.object({ endValid: z.number(), userId: z.int(), isRevoked: z.boolean(), + tokenName: z.string().optional(), token: z.string().optional() }) })