diff --git a/src/app/features/api-client/api-client-list/api-client-list.component.html b/src/app/features/api-client/api-client-list/api-client-list.component.html index abbda1c43b..4fbf0faad1 100644 --- a/src/app/features/api-client/api-client-list/api-client-list.component.html +++ b/src/app/features/api-client/api-client-list/api-client-list.component.html @@ -74,6 +74,16 @@ + + + diff --git a/src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.spec.ts b/src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.spec.ts new file mode 100644 index 0000000000..9794f34fcf --- /dev/null +++ b/src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { HttpErrorResponse } from "@angular/common/http"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; +import { HarnessLoader } from "@angular/cdk/testing"; +import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed"; +import { MatButtonHarness } from "@angular/material/button/testing"; +import { MatSelectionListHarness } from "@angular/material/list/testing"; +import type { Mock } from "vitest"; +import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; +import { EditApiClientScopesDialogComponent } from "./edit-api-client-scopes-dialog.component"; +import { ApiClient, ApiClientService } from "../api-client.service"; + +describe("EditApiClientScopesDialogComponent", () => { + let fixture: ComponentFixture; + let loader: HarnessLoader; + let apiClientService: { getScopes: Mock; updateScopes: Mock }; + let dialogRef: { close: Mock }; + + const apiClient: ApiClient = { + clientId: "api-crm-x7k2pq", + name: "CRM", + scopes: ["reporting_read"], + }; + + beforeEach(async () => { + apiClientService = { + getScopes: vi.fn().mockResolvedValue([ + { name: "reporting_read", description: "" }, + { name: "reporting_write", description: "" }, + ]), + updateScopes: vi.fn(), + }; + dialogRef = { close: vi.fn() }; + await TestBed.configureTestingModule({ + imports: [EditApiClientScopesDialogComponent, FontAwesomeTestingModule], + providers: [ + { provide: ApiClientService, useValue: apiClientService }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: MAT_DIALOG_DATA, useValue: apiClient }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EditApiClientScopesDialogComponent); + loader = TestbedHarnessEnvironment.loader(fixture); + fixture.detectChanges(); + await fixture.whenStable(); + }); + + async function changeAccessAndSave() { + const list = await loader.getHarness(MatSelectionListHarness); + await list.deselectItems({ title: /Read reports/ }); + await list.selectItems({ title: /Calculate reports/ }); + await ( + await loader.getHarness(MatButtonHarness.with({ text: "Save" })) + ).click(); + } + + it("changes the access of the API client and passes the updated client on", async () => { + const updated = { ...apiClient, scopes: ["reporting_write"] }; + apiClientService.updateScopes.mockResolvedValue(updated); + + await changeAccessAndSave(); + + await vi.waitFor(() => + expect(dialogRef.close).toHaveBeenCalledWith(updated), + ); + expect(apiClientService.updateScopes).toHaveBeenCalledWith( + "api-crm-x7k2pq", + ["reporting_write"], + ); + }); + + it("shows why the backend refused to change the access", async () => { + apiClientService.updateScopes.mockRejectedValue( + new HttpErrorResponse({ + status: 404, + error: { errorMessage: "There is no API client 'api-crm-x7k2pq'" }, + }), + ); + + await changeAccessAndSave(); + + await vi.waitFor(() => { + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector('[role="alert"]')?.textContent, + ).toContain("There is no API client"); + }); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.ts b/src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.ts new file mode 100644 index 0000000000..04766f42b5 --- /dev/null +++ b/src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.ts @@ -0,0 +1,67 @@ +import { Component, inject, signal } from "@angular/core"; +import { FormControl, Validators } from "@angular/forms"; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef, +} from "@angular/material/dialog"; +import { MatButtonModule } from "@angular/material/button"; +import { DialogCloseComponent } from "#src/app/core/common-components/dialog-close/dialog-close.component"; +import { + ApiClient, + ApiClientService, + errorMessageOf, + logRequestError, +} from "../api-client.service"; +import { ApiClientScopeSelectionComponent } from "../api-client-scope-selection/api-client-scope-selection.component"; + +/** + * Change the access (client scopes) of an existing API client. + * Closes with the updated {@link ApiClient}, if it was changed. + */ +@Component({ + selector: "app-edit-api-client-scopes-dialog", + imports: [ + MatDialogModule, + MatButtonModule, + DialogCloseComponent, + ApiClientScopeSelectionComponent, + ], + templateUrl: "./edit-api-client-scopes-dialog.component.html", +}) +export class EditApiClientScopesDialogComponent { + private readonly apiClientService = inject(ApiClientService); + private readonly dialogRef = + inject>( + MatDialogRef, + ); + readonly apiClient = inject(MAT_DIALOG_DATA); + + readonly scopes = new FormControl([...this.apiClient.scopes], { + nonNullable: true, + validators: [Validators.required], + }); + readonly saving = signal(false); + readonly error = signal(undefined); + + async save() { + if (this.scopes.invalid || this.saving()) { + return; + } + this.saving.set(true); + this.error.set(undefined); + + try { + const updated = await this.apiClientService.updateScopes( + this.apiClient.clientId, + this.scopes.value, + ); + this.dialogRef.close(updated); + } catch (err) { + logRequestError("Could not change the scopes of an API client", err); + this.error.set(errorMessageOf(err)); + } finally { + this.saving.set(false); + } + } +}