From 3510161b65237bea2f0f5d818c21717dbea9563c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:24:36 +0000 Subject: [PATCH 1/3] refactor(api-client): extract the selection of client scopes into its own component So that it can be reused to change the scopes of an existing API client. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fb2hmonEJ4GPn7E8vpSMBC --- .../api-client-scope-selection.component.html | 28 +++++++++ .../api-client-scope-selection.component.ts | 62 +++++++++++++++++++ .../create-api-client-dialog.component.html | 29 +-------- .../create-api-client-dialog.component.ts | 37 ++--------- 4 files changed, 98 insertions(+), 58 deletions(-) create mode 100644 src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.html create mode 100644 src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.ts diff --git a/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.html b/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.html new file mode 100644 index 0000000000..3f82a31744 --- /dev/null +++ b/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.html @@ -0,0 +1,28 @@ +@if (scopes.hasValue()) { + @let offeredScopes = scopes.value(); + + @for (scope of offeredScopes; track scope.name) { + + {{ scope.label }} + + } + +} @else if (scopes.isLoading()) { + +} @else if (loadingError(); as loadingError) { + +} + +@if (singleSignOnSelected()) { + + + With single sign-on, the external system can sign in to this system as any + of its users, including administrators, without their password. Only give + this access to systems you fully trust. + + +} diff --git a/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.ts b/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.ts new file mode 100644 index 0000000000..cfa607e979 --- /dev/null +++ b/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.ts @@ -0,0 +1,62 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + resource, +} from "@angular/core"; +import { toObservable, toSignal } from "@angular/core/rxjs-interop"; +import { FormControl, ReactiveFormsModule } from "@angular/forms"; +import { MatListModule } from "@angular/material/list"; +import { MatProgressBarModule } from "@angular/material/progress-bar"; +import { startWith, switchMap } from "rxjs"; +import { HintBoxComponent } from "#src/app/core/common-components/hint-box/hint-box.component"; +import { ApiClientService, errorMessageOf } from "../api-client.service"; +import { getScopeLabel, SINGLE_SIGN_ON_SCOPE } from "../api-client-scopes"; + +/** + * Select the access (client scopes) of an API client, from those the backend offers. + */ +@Component({ + selector: "app-api-client-scope-selection", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MatListModule, + MatProgressBarModule, + ReactiveFormsModule, + HintBoxComponent, + ], + templateUrl: "./api-client-scope-selection.component.html", +}) +export class ApiClientScopeSelectionComponent { + private readonly apiClientService = inject(ApiClientService); + + /** the names of the selected client scopes */ + readonly control = input.required>(); + + /** the client scopes the API client can be given */ + readonly scopes = resource({ + loader: async () => + (await this.apiClientService.getScopes()).map((scope) => ({ + name: scope.name, + label: getScopeLabel(scope.name, scope.description), + })), + }); + readonly loadingError = computed(() => + this.scopes.error() ? errorMessageOf(this.scopes.error()) : undefined, + ); + + private readonly selectedScopes = toSignal( + toObservable(this.control).pipe( + switchMap((control) => + control.valueChanges.pipe(startWith(control.value)), + ), + ), + { initialValue: [] as string[] }, + ); + + readonly singleSignOnSelected = computed(() => + this.selectedScopes().includes(SINGLE_SIGN_ON_SCOPE), + ); +} diff --git a/src/app/features/api-client/create-api-client-dialog/create-api-client-dialog.component.html b/src/app/features/api-client/create-api-client-dialog/create-api-client-dialog.component.html index c1ed9306c1..472956b429 100644 --- a/src/app/features/api-client/create-api-client-dialog/create-api-client-dialog.component.html +++ b/src/app/features/api-client/create-api-client-dialog/create-api-client-dialog.component.html @@ -42,32 +42,9 @@

New API client

Access

- @if (scopes.hasValue()) { - @let offeredScopes = scopes.value(); - - @for (scope of offeredScopes; track scope.name) { - - {{ scope.label }} - - } - - } @else if (scopes.isLoading()) { - - } - - @if (singleSignOnSelected()) { - - - With single sign-on, the external system can sign in to this system as - any of its users, including administrators, without their password. - Only give this access to systems you fully trust. - - - } + @if (error(); as error) { diff --git a/src/app/features/api-client/create-api-client-dialog/create-api-client-dialog.component.ts b/src/app/features/api-client/create-api-client-dialog/create-api-client-dialog.component.ts index b93b859d33..11ab46212e 100644 --- a/src/app/features/api-client/create-api-client-dialog/create-api-client-dialog.component.ts +++ b/src/app/features/api-client/create-api-client-dialog/create-api-client-dialog.component.ts @@ -3,7 +3,6 @@ import { Component, computed, inject, - resource, signal, } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; @@ -16,17 +15,14 @@ import { import { MatDialogModule, MatDialogRef } from "@angular/material/dialog"; import { MatButtonModule } from "@angular/material/button"; import { MatInputModule } from "@angular/material/input"; -import { MatListModule } from "@angular/material/list"; -import { MatProgressBarModule } from "@angular/material/progress-bar"; import { DialogCloseComponent } from "#src/app/core/common-components/dialog-close/dialog-close.component"; -import { HintBoxComponent } from "#src/app/core/common-components/hint-box/hint-box.component"; import { API_CLIENT_ID_PREFIX, ApiClientService, errorMessageOf, logRequestError, } from "../api-client.service"; -import { getScopeLabel, SINGLE_SIGN_ON_SCOPE } from "../api-client-scopes"; +import { ApiClientScopeSelectionComponent } from "../api-client-scope-selection/api-client-scope-selection.component"; import { ApiClientCredentialsDialogData } from "../api-client-credentials-dialog/api-client-credentials-dialog.component"; /** @@ -40,11 +36,9 @@ import { ApiClientCredentialsDialogData } from "../api-client-credentials-dialog MatDialogModule, MatButtonModule, MatInputModule, - MatListModule, - MatProgressBarModule, ReactiveFormsModule, DialogCloseComponent, - HintBoxComponent, + ApiClientScopeSelectionComponent, ], templateUrl: "./create-api-client-dialog.component.html", }) @@ -83,26 +77,9 @@ export class CreateApiClientDialogComponent { readonly CLIENT_ID_PREFIX = API_CLIENT_ID_PREFIX; - /** the client scopes the API client can be given */ - readonly scopes = resource({ - loader: async () => - (await this.apiClientService.getScopes()).map((scope) => ({ - name: scope.name, - label: getScopeLabel(scope.name, scope.description), - })), - }); readonly saving = signal(false); - private readonly createError = signal(undefined); - readonly error = computed( - () => - this.createError() ?? - (this.scopes.error() ? errorMessageOf(this.scopes.error()) : undefined), - ); + readonly error = signal(undefined); - private readonly selectedScopes = toSignal( - this.form.controls.scopes.valueChanges, - { initialValue: [] as string[] }, - ); private readonly clientIdValue = toSignal( this.form.controls.clientId.valueChanges, { initialValue: "" }, @@ -110,16 +87,12 @@ export class CreateApiClientDialogComponent { /** the part of the client ID the user chose, if any */ readonly chosenClientId = computed(() => this.clientIdValue().trim()); - readonly singleSignOnSelected = computed(() => - this.selectedScopes().includes(SINGLE_SIGN_ON_SCOPE), - ); - async create() { if (this.form.invalid || this.saving()) { return; } this.saving.set(true); - this.createError.set(undefined); + this.error.set(undefined); const { name, clientId, scopes } = this.form.getRawValue(); try { @@ -131,7 +104,7 @@ export class CreateApiClientDialogComponent { this.dialogRef.close({ credentials, scopes }); } catch (err) { logRequestError("Could not create API client", err); - this.createError.set(errorMessageOf(err)); + this.error.set(errorMessageOf(err)); } finally { this.saving.set(false); } From aefa19a11f70b6eb09b91f26d1ee100599f6ace8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 13:27:32 +0000 Subject: [PATCH 2/3] feat(api-client): change the access (client scopes) of an existing API client Account managers can change which client scopes an API client has from the API Access admin page, without creating new credentials for the external system. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fb2hmonEJ4GPn7E8vpSMBC --- .../api-client-list.component.html | 10 ++ .../api-client-list.component.spec.ts | 28 ++++++ .../api-client-list.component.ts | 12 +++ .../features/api-client/api-client.service.ts | 13 +++ ...it-api-client-scopes-dialog.component.html | 32 +++++++ ...api-client-scopes-dialog.component.spec.ts | 91 +++++++++++++++++++ ...edit-api-client-scopes-dialog.component.ts | 73 +++++++++++++++ 7 files changed, 259 insertions(+) create mode 100644 src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.html create mode 100644 src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.spec.ts create mode 100644 src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.ts 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..b4131630af --- /dev/null +++ b/src/app/features/api-client/edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component.ts @@ -0,0 +1,73 @@ +import { + ChangeDetectionStrategy, + 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", + changeDetection: ChangeDetectionStrategy.OnPush, + 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); + } + } +} From 30e10770147cc0a475f982380996fc33df1c354d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:16:25 +0000 Subject: [PATCH 3/3] refactor(api-client): drop the explicit OnPush change detection, the default since Angular 22 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fb2hmonEJ4GPn7E8vpSMBC --- .../api-client-scope-selection.component.ts | 10 +--------- .../edit-api-client-scopes-dialog.component.ts | 8 +------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.ts b/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.ts index cfa607e979..9710a1b483 100644 --- a/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.ts +++ b/src/app/features/api-client/api-client-scope-selection/api-client-scope-selection.component.ts @@ -1,11 +1,4 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - input, - resource, -} from "@angular/core"; +import { Component, computed, inject, input, resource } from "@angular/core"; import { toObservable, toSignal } from "@angular/core/rxjs-interop"; import { FormControl, ReactiveFormsModule } from "@angular/forms"; import { MatListModule } from "@angular/material/list"; @@ -20,7 +13,6 @@ import { getScopeLabel, SINGLE_SIGN_ON_SCOPE } from "../api-client-scopes"; */ @Component({ selector: "app-api-client-scope-selection", - changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatListModule, MatProgressBarModule, 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 index b4131630af..04766f42b5 100644 --- 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 @@ -1,9 +1,4 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - signal, -} from "@angular/core"; +import { Component, inject, signal } from "@angular/core"; import { FormControl, Validators } from "@angular/forms"; import { MAT_DIALOG_DATA, @@ -26,7 +21,6 @@ import { ApiClientScopeSelectionComponent } from "../api-client-scope-selection/ */ @Component({ selector: "app-edit-api-client-scopes-dialog", - changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatDialogModule, MatButtonModule,