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