Skip to content

Commit

Permalink
NAS-132129 / 25.04 / Further improvements for user-linked API keys (#…
Browse files Browse the repository at this point in the history
…11009)

* NAS-132129: Further improvements for user-linked API keys

* NAS-132129: PR update

* NAS-132129: NAs-132129: PR update

* NAS-132129: PR update
  • Loading branch information
AlexKarpov98 authored Nov 12, 2024
1 parent 21520fe commit 42f9d8c
Show file tree
Hide file tree
Showing 105 changed files with 708 additions and 127 deletions.
9 changes: 9 additions & 0 deletions src/app/helptext/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,19 @@ export const helptextApiKeys = {
tooltip: T('Descriptive identifier for this API key.'),
},

expires: {
tooltip: T('Set an expiration date-time for the API key.'),
},

username: {
tooltip: T('Username associated with this API key.'),
},

nonExpiring: {
tooltip: T('Enable this to create a token with no expiration date. The token will stay active\
until it is manually revoked or updated.'),
},

reset: {
tooltip: T('Remove the existing API key and generate a new random key.\
A dialog shows the new key and has an option to copy the key. Back up and\
Expand Down
3 changes: 2 additions & 1 deletion src/app/interfaces/api-key.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,13 @@ export interface ApiKey {
export interface CreateApiKeyRequest {
name: string;
username: string;
expires_at?: string;
expires_at?: ApiTimestamp;
}

export type UpdateApiKeyRequest = [number, {
name: string;
reset?: boolean;
expires_at?: ApiTimestamp;
}];

export interface ApiKeyAllowListItem {
Expand Down
4 changes: 4 additions & 0 deletions src/app/interfaces/option.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@ export interface SelectOption<T = SelectOptionValueType> extends Option<T> {
hoverTooltip?: string;
}

export interface ActionOption<T = BaseOptionValueType> extends Option<T> {
action?: () => void;
}

export const newOption = 'NEW';
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
@for (option of data(); track option.label) {
<div>
<dt>{{ option.label | translate }}:</dt>
<dd>{{ option.value?.toString() || '–' }}</dd>
<dd
[class.clickable]="!!option.action"
(click)="!!option.action ? option.action() : null"
>
{{ option.value?.toString() || '–' }}
</dd>
</div>
}
</dl>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@
}
}
}

.clickable {
color: var(--primary);
cursor: pointer;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
} from '@angular/core';
import { MatDivider } from '@angular/material/divider';
import { TranslateModule } from '@ngx-translate/core';
import { Option } from 'app/interfaces/option.interface';
import { ActionOption } from 'app/interfaces/option.interface';

@Component({
selector: 'ix-table-expandable-row',
Expand All @@ -14,5 +14,5 @@ import { Option } from 'app/interfaces/option.interface';
imports: [MatDivider, TranslateModule],
})
export class IxTableExpandableRowComponent {
readonly data = input<Option[]>();
readonly data = input<ActionOption[]>();
}
15 changes: 11 additions & 4 deletions src/app/modules/layout/topbar/user-menu/user-menu.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,17 @@
{{ 'Two-Factor Authentication' | translate }}
</button>

<a name="settings-api" ixTest="api-keys" mat-menu-item [routerLink]="['/credentials/user-api-keys']">
<ix-icon name="laptop"></ix-icon>
{{ 'API Keys' | translate }}
</a>
@if (loggedInUser$ | async; as user) {
<a
name="settings-api"
ixTest="api-keys"
mat-menu-item
[routerLink]="['/credentials/user-api-keys']"
[queryParams]="{ userName: user.pw_name }">
<ix-icon name="laptop"></ix-icon>
{{ 'My API Keys' | translate }}
</a>
}

<a
name="settings-guide"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ describe('UserMenuComponent', () => {
});

it('has an API Keys menu item that takes user to list of API Keys', async () => {
const apiKeys = await menu.getItems({ text: /API Keys$/ });
const apiKeys = await menu.getItems({ text: /My API Keys$/ });
const apiKeysElement = await apiKeys[0].host();

expect(await apiKeysElement.getAttribute('href')).toBe('/credentials/user-api-keys');
expect(await apiKeysElement.getAttribute('href')).toBe('/credentials/user-api-keys?userName=root');
});

it('has a Guide menu item that opens user guide', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { Router, RouterLink } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateModule } from '@ngx-translate/core';
import { User } from '@sentry/angular';
import { filter } from 'rxjs';
import { UiSearchDirective } from 'app/directives/ui-search.directive';
import { AccountAttribute } from 'app/enums/account-attribute.enum';
Expand Down Expand Up @@ -78,4 +79,10 @@ export class UserMenuComponent {
this.router.navigate(['/signin']);
});
}

viewUserApiKeys(user: User): void {
this.router.navigate(['/credentials/user-api-keys'], {
queryParams: { userName: user.username },
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,23 @@
></ix-combobox>
}

@if (!isNew()) {
<ix-checkbox
formControlName="nonExpiring"
[label]="'Non expiring' | translate"
[tooltip]="tooltips.nonExpiring| translate"
></ix-checkbox>

<!-- TODO: https://ixsystems.atlassian.net/browse/NAS-132423 (Implement IxDatePickerComponent) -->
@if (!form.controls.nonExpiring.value) {
<ix-input
formControlName="expiresAt"
[label]="'Expires at' | translate"
[tooltip]="tooltips.expires | translate"
[required]="true"
></ix-input>
}

@if (!isNew() && isAllowedToReset()) {
<ix-checkbox
formControlName="reset"
[label]="'Reset' | translate"
Expand All @@ -41,7 +57,7 @@
mat-button type="submit"
color="primary"
ixTest="save"
[disabled]="form.invalid && isLoading()"
[disabled]="form.invalid || isLoading()"
>{{ 'Save' | translate }}</button>
</ix-form-actions>
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ describe('ApiKeyFormComponent', () => {
expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('api_key.create', [{
name: 'My key',
username: 'root',
expires_at: null,
}]);
expect(spectator.inject(SlideInRef).close).toHaveBeenCalledWith(true);
expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith(KeyCreatedDialogComponent, {
Expand All @@ -82,18 +83,21 @@ describe('ApiKeyFormComponent', () => {
await setupTest({
id: 1,
name: 'existing key',
username: 'root',
});

await form.fillForm({
Name: 'My key',
'Non expiring': true,
});

const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));
await saveButton.click();

expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('api_key.update', [1, {
expect(spectator.inject(WebSocketService).call).toHaveBeenLastCalledWith('api_key.update', [1, {
name: 'My key',
reset: false,
expires_at: null,
}]);
expect(spectator.inject(SlideInRef).close).toHaveBeenCalledWith(true);
expect(spectator.inject(MatDialog).open).not.toHaveBeenCalledWith(KeyCreatedDialogComponent, {
Expand All @@ -105,12 +109,15 @@ describe('ApiKeyFormComponent', () => {
await setupTest({
id: 1,
name: 'existing key',
username: 'root',
});
spectator.inject(MockWebSocketService).mockCallOnce('api_key.update', { key: 'generated-key' } as ApiKey);

await form.fillForm({
Name: 'My key',
Reset: true,
'Non expiring': false,
'Expires at': '2024-12-31T23:59:59.000Z',
});

const saveButton = await loader.getHarness(MatButtonHarness.with({ text: 'Save' }));
Expand All @@ -119,6 +126,9 @@ describe('ApiKeyFormComponent', () => {
expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('api_key.update', [1, {
name: 'My key',
reset: true,
expires_at: {
$date: NaN,
},
}]);
expect(spectator.inject(SlideInRef).close).toHaveBeenCalledWith(true);
expect(spectator.inject(MatDialog).open).toHaveBeenCalledWith(KeyCreatedDialogComponent, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { RequiresRolesDirective } from 'app/directives/requires-roles/requires-r
import { Role } from 'app/enums/role.enum';
import { ParamsBuilder } from 'app/helpers/params-builder/params-builder.class';
import { helptextApiKeys } from 'app/helptext/api-keys';
import { ApiTimestamp } from 'app/interfaces/api-date.interface';
import { ApiKey } from 'app/interfaces/api-key.interface';
import { User } from 'app/interfaces/user.interface';
import { SimpleAsyncComboboxProvider } from 'app/modules/forms/ix-forms/classes/simple-async-combobox-provider';
Expand All @@ -29,7 +30,9 @@ import { ModalHeaderComponent } from 'app/modules/slide-ins/components/modal-hea
import { SlideInRef } from 'app/modules/slide-ins/slide-in-ref';
import { SLIDE_IN_DATA } from 'app/modules/slide-ins/slide-in.token';
import { TestDirective } from 'app/modules/test-id/test.directive';
import { KeyCreatedDialogComponent } from 'app/pages/credentials/users/user-api-keys/components/key-created-dialog/key-created-dialog.component';
import {
KeyCreatedDialogComponent,
} from 'app/pages/credentials/users/user-api-keys/components/key-created-dialog/key-created-dialog.component';
import { AuthService } from 'app/services/auth/auth.service';
import { WebSocketService } from 'app/services/ws.service';

Expand Down Expand Up @@ -61,17 +64,25 @@ export class ApiKeyFormComponent implements OnInit {
protected readonly isLoading = signal(false);
protected readonly requiredRoles = [Role.ApiKeyWrite, Role.SharingAdmin, Role.ReadonlyAdmin];
protected readonly isFullAdmin = toSignal(this.authService.hasRole([Role.FullAdmin]));
protected readonly isAllowedToReset = computed(
() => this.username() === this.form.value.username || this.isFullAdmin(),
);

protected readonly currentUsername$ = this.authService.user$.pipe(map((user) => user.pw_name));
protected readonly username = toSignal(this.currentUsername$);
protected readonly tooltips = {
name: helptextApiKeys.name.tooltip,
expires: helptextApiKeys.expires.tooltip,
username: helptextApiKeys.username.tooltip,
reset: helptextApiKeys.reset.tooltip,
nonExpiring: helptextApiKeys.nonExpiring.tooltip,
};

protected readonly form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(200)]],
username: ['', [Validators.required]],
expiresAt: [null as string],
nonExpiring: [true],
reset: [false],
});

Expand Down Expand Up @@ -104,20 +115,30 @@ export class ApiKeyFormComponent implements OnInit {
) {}

ngOnInit(): void {
this.addForbiddenNamesValidator();
this.setCurrentUsername();

if (!this.isNew()) {
this.form.patchValue(this.editingRow());
if (this.isNew()) {
this.addForbiddenNamesValidator();
this.setCurrentUsername();
} else {
this.form.patchValue({
...this.editingRow(),
expiresAt: this.editingRow().expires_at?.$date?.toString() || null,
nonExpiring: !this.editingRow().expires_at,
});
}
}

onSubmit(): void {
this.isLoading.set(true);
const { name, username, reset } = this.form.value;
const {
name, username, reset, nonExpiring, expiresAt,
} = this.form.value;

// TODO: Implement IxDatePickerComponent https://ixsystems.atlassian.net/browse/NAS-132423 and correctly send expires_at prop
const expiresAtTimestamp = nonExpiring ? null : { $date: +expiresAt } as ApiTimestamp;

const request$ = this.isNew()
? this.ws.call('api_key.create', [{ name, username }])
: this.ws.call('api_key.update', [this.editingRow().id, { name, reset }]);
? this.ws.call('api_key.create', [{ name, username, expires_at: expiresAtTimestamp }])
: this.ws.call('api_key.update', [this.editingRow().id, { name, reset, expires_at: expiresAtTimestamp }]);

request$
.pipe(this.loader.withLoader(), untilDestroyed(this))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,20 @@ describe('UserApiKeysComponent', () => {
id: 1,
name: 'first-api-key',
username: 'root',
keyhash: 'strong-key',
local: true,
revoked: false,
created_at: {
$date: 1010101010101,
},
expires_at: {
$date: 1013001010101,
},
}, {
id: 2,
name: 'second-api-key',
username: 'root',
keyhash: 'strong-key',
local: false,
revoked: true,
created_at: {
$date: 1011101010102,
},
Expand Down Expand Up @@ -91,26 +96,26 @@ describe('UserApiKeysComponent', () => {

it('should show table rows', async () => {
const expectedRows = [
['Name', 'Username', 'Keyhash', 'Created date', 'Expires date', ''],
['first-api-key', 'root', 'strong-key', '2002-01-03 15:36:50', 'Never', ''],
['second-api-key', 'root', 'strong-key', '2002-01-15 05:23:30', 'Never', ''],
['Name', 'Username', 'Local', 'Revoked', 'Created date', 'Expires date', ''],
['first-api-key', 'root', 'Yes', 'No', '2002-01-03 15:36:50', '2002-02-06 05:10:10', ''],
['second-api-key', 'root', 'No', 'Yes', '2002-01-15 05:23:30', 'N/A', ''],
];

const cells = await table.getCellTexts();
expect(cells).toEqual(expectedRows);
});

it('shows form to edit an existing Smart Task when Edit button is pressed', async () => {
const editButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'mdi-pencil' }), 1, 5);
it('shows form to edit an existing API Key when Edit button is pressed', async () => {
const editButton = await table.getHarnessInCell(IxIconHarness.with({ name: 'mdi-pencil' }), 1, 6);
await editButton.click();

expect(spectator.inject(SlideInService).open).toHaveBeenCalledWith(ApiKeyFormComponent, {
data: apiKeys[0],
});
});

it('deletes a Smart Task with confirmation when Delete button is pressed', async () => {
const deleteIcon = await table.getHarnessInCell(IxIconHarness.with({ name: 'mdi-delete' }), 1, 5);
it('deletes a API Key with confirmation when Delete button is pressed', async () => {
const deleteIcon = await table.getHarnessInCell(IxIconHarness.with({ name: 'mdi-delete' }), 1, 6);
await deleteIcon.click();

expect(spectator.inject(DialogService).confirm).toHaveBeenCalledWith({
Expand Down
Loading

0 comments on commit 42f9d8c

Please sign in to comment.