Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@

<div class="flex flex-wrap items-start gap-2">
<input-text title="ID" [value]="token.id + ''" [readonly]="true"></input-text>
<input-text
title="Token Name"
[value]="token.tokenName?.trim() || '—'"
[readonly]="true"
></input-text>
<input-text
title="Status"
[value]="status ?? '' | titlecase"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@
<span class="help-block text-critical">"Valid Until" must be on or after "Valid From".</span>
}

<input-text
title="Token Name"
formControlName="tokenName"
hint="Optional label to identify this API key"
[maxLength]="100"
></input-text>

<div class="flex flex-col gap-2 mt-2">
<span class="font-medium">Scopes</span>
@if (loadingScopes) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/app/account/api-keys/new-api-key/new-api-key.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
validFrom: FormControl<Date | null>;
validUntil: FormControl<Date | null>;
scopes: FormControl<PermissionValues[]>;
Expand All @@ -36,6 +37,7 @@ export const getNewApiKeyForm = (validityDays: number): FormGroup<NewApiKeyForm>

return new FormGroup<NewApiKeyForm>(
{
tokenName: new FormControl<string>('', { nonNullable: true }),
validFrom: new FormControl<Date | null>(validFrom, [Validators.required]),
validUntil: new FormControl<Date | null>(validUntil, [Validators.required]),
scopes: new FormControl<PermissionValues[]>([], { nonNullable: true, validators: [Validators.required] })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
[isFilterable]="false"
[isPageable]="true"
(rowActionClicked)="rowActionClicked($event)"
(editableSaved)="editableSaved($event)"
/>
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;

Expand All @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -127,6 +155,38 @@ export class ApiTokensTableComponent extends BaseTableComponent implements OnIni
return status.charAt(0).toUpperCase() + status.slice(1);
}

// --- Inline editing ---

editableSaved(editable: HTTableEditable<JApiToken>): 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<void> {
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<JApiToken>): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -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, string> = {
[ApiTokensTableCol.ID]: 'ID',
[ApiTokensTableCol.TOKEN_NAME]: 'Token Name',
[ApiTokensTableCol.VALID_FROM]: 'Valid From',
[ApiTokensTableCol.VALID_UNTIL]: 'Expires At',
[ApiTokensTableCol.STATUS]: 'Status',
Expand All @@ -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];
1 change: 1 addition & 0 deletions src/app/core/_models/api-token.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface JApiToken extends BaseModel {
endValid: number;
userId: UserId;
isRevoked: boolean;
tokenName?: string | undefined;
token?: string | undefined;
user?: JUser | null;
}
Expand Down
5 changes: 5 additions & 0 deletions src/generated/api/types/api-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type ApiTokenCreate = {
endValid: number;
userId: number;
isRevoked: boolean;
tokenName?: string;
};
};
};
Expand All @@ -18,6 +19,7 @@ export type ApiTokenPatch = {
type: 'apiToken';
attributes: {
isRevoked?: boolean;
tokenName?: string;
};
};
};
Expand All @@ -42,6 +44,7 @@ export type ApiTokenResponse = {
endValid: number;
userId: number;
isRevoked: boolean;
tokenName?: string;
token?: string;
};
};
Expand Down Expand Up @@ -91,6 +94,7 @@ export type ApiTokenPostPatchResponse = {
endValid: number;
userId: number;
isRevoked: boolean;
tokenName?: string;
token?: string;
};
};
Expand All @@ -116,6 +120,7 @@ export type ApiTokenListResponse = {
endValid: number;
userId: number;
isRevoked: boolean;
tokenName?: string;
token?: string;
};
}>;
Expand Down
9 changes: 7 additions & 2 deletions src/generated/api/zod/api-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
});
Expand All @@ -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()
})
})
});
Expand All @@ -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()
})
}),
Expand Down Expand Up @@ -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()
})
})
Expand Down Expand Up @@ -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()
})
})
Expand Down
Loading