Skip to content
Merged
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 @@ -74,6 +74,16 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let apiClient" class="text-align-end">
<button
mat-icon-button
(click)="editScopes(apiClient)"
matTooltip="Change access"
i18n-matTooltip
aria-label="Change access"
i18n-aria-label
>
<fa-icon icon="pen"></fa-icon>
</button>
<button
mat-icon-button
(click)="regenerateSecret(apiClient)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ConfirmationDialogService } from "#src/app/core/common-components/confi
import { ApiClientListComponent } from "./api-client-list.component";
import { ApiClient, ApiClientService } from "../api-client.service";
import { CreateApiClientDialogComponent } from "../create-api-client-dialog/create-api-client-dialog.component";
import { EditApiClientScopesDialogComponent } from "../edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component";
import { ApiClientCredentialsDialogComponent } from "../api-client-credentials-dialog/api-client-credentials-dialog.component";

describe("ApiClientListComponent", () => {
Expand Down Expand Up @@ -143,6 +144,33 @@ describe("ApiClientListComponent", () => {
},
);

it.each([
[{ ...existingClient, scopes: ["reporting_write"] }, 2],
[undefined, 1],
])(
"reloads the list only if the access of an API client was changed (%#)",
async (dialogResult, expectedLoads) => {
await render({ available: true, dialogResult });
await vi.waitFor(() =>
expect(apiClientService.getApiClients).toHaveBeenCalledTimes(1),
);

await clickButton("Change access");

expect(openDialog).toHaveBeenCalledWith(
EditApiClientScopesDialogComponent,
expect.objectContaining({
data: expect.objectContaining(existingClient),
}),
);
await vi.waitFor(() =>
expect(apiClientService.getApiClients).toHaveBeenCalledTimes(
expectedLoads,
),
);
},
);

it("deletes an API client after confirmation and reloads the list", async () => {
await render({ available: true });
await vi.waitFor(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "../api-client.service";
import { getScopeLabel } from "../api-client-scopes";
import { CreateApiClientDialogComponent } from "../create-api-client-dialog/create-api-client-dialog.component";
import { EditApiClientScopesDialogComponent } from "../edit-api-client-scopes-dialog/edit-api-client-scopes-dialog.component";
import {
ApiClientCredentialsDialogComponent,
ApiClientCredentialsDialogData,
Expand Down Expand Up @@ -96,6 +97,17 @@ export class ApiClientListComponent {
}
}

async editScopes(apiClient: ApiClient) {
const dialogRef = this.dialog.open<
EditApiClientScopesDialogComponent,
ApiClient,
ApiClient
>(EditApiClientScopesDialogComponent, { data: apiClient, width: "600px" });
if (await firstValueFrom(dialogRef.afterClosed())) {
this.apiClients.reload();
}
}

async regenerateSecret(apiClient: ApiClient) {
const confirmed = await this.confirmationDialog.getConfirmation(
$localize`Generate new secret?`,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
@if (scopes.hasValue()) {
@let offeredScopes = scopes.value();
<mat-selection-list
[formControl]="control()"
aria-label="Access"
i18n-aria-label
>
@for (scope of offeredScopes; track scope.name) {
<mat-list-option [value]="scope.name">
<span matListItemTitle>{{ scope.label }}</span>
</mat-list-option>
}
</mat-selection-list>
} @else if (scopes.isLoading()) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
} @else if (loadingError(); as loadingError) {
<p class="color-error" role="alert">{{ loadingError }}</p>
}

@if (singleSignOnSelected()) {
<app-hint-box type="warning" class="margin-top-small">
<span i18n>
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.
</span>
</app-hint-box>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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";
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",
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<FormControl<string[]>>();

/** 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),
);
}
13 changes: 13 additions & 0 deletions src/app/features/api-client/api-client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@ export class ApiClientService {
);
}

/**
* Replace the client scopes (the access) of the API client.
* Access tokens the external system got before keep their previous access until they expire.
*/
updateScopes(clientId: string, scopes: string[]): Promise<ApiClient> {
return firstValueFrom(
this.http.put<ApiClient>(
`${this.API_URL}/${encodeURIComponent(clientId)}/scope`,
{ scopes },
),
);
}

async deleteApiClient(clientId: string): Promise<void> {
await firstValueFrom(
this.http.delete(`${this.API_URL}/${encodeURIComponent(clientId)}`),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,32 +42,9 @@ <h2 mat-dialog-title i18n>New API client</h2>
</mat-form-field>

<h3 i18n>Access</h3>
@if (scopes.hasValue()) {
@let offeredScopes = scopes.value();
<mat-selection-list
formControlName="scopes"
aria-label="Access"
i18n-aria-label
>
@for (scope of offeredScopes; track scope.name) {
<mat-list-option [value]="scope.name">
<span matListItemTitle>{{ scope.label }}</span>
</mat-list-option>
}
</mat-selection-list>
} @else if (scopes.isLoading()) {
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
}

@if (singleSignOnSelected()) {
<app-hint-box type="warning" class="margin-top-small">
<span i18n>
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.
</span>
</app-hint-box>
}
<app-api-client-scope-selection
[control]="form.controls.scopes"
></app-api-client-scope-selection>

@if (error(); as error) {
<p class="color-error" role="alert">{{ error }}</p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
Component,
computed,
inject,
resource,
signal,
} from "@angular/core";
import { toSignal } from "@angular/core/rxjs-interop";
Expand All @@ -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";

/**
Expand All @@ -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",
})
Expand Down Expand Up @@ -83,43 +77,22 @@ 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<string | undefined>(undefined);
readonly error = computed(
() =>
this.createError() ??
(this.scopes.error() ? errorMessageOf(this.scopes.error()) : undefined),
);
readonly error = signal<string | undefined>(undefined);

private readonly selectedScopes = toSignal(
this.form.controls.scopes.valueChanges,
{ initialValue: [] as string[] },
);
private readonly clientIdValue = toSignal(
this.form.controls.clientId.valueChanges,
{ initialValue: "" },
);
/** 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 {
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<h2 mat-dialog-title i18n>Access of "{{ apiClient.name }}"</h2>
<app-dialog-close mat-dialog-close></app-dialog-close>

<mat-dialog-content>
<p i18n>
Choose what the external system can access through the API. It keeps its
current credentials. Access tokens it already got keep their previous access
until they expire.
</p>

<app-api-client-scope-selection
[control]="scopes"
></app-api-client-scope-selection>

@if (error(); as error) {
<p class="color-error" role="alert">{{ error }}</p>
}
</mat-dialog-content>

<mat-dialog-actions>
<button
mat-raised-button
color="accent"
type="button"
(click)="save()"
[disabled]="scopes.invalid || scopes.pristine || saving()"
i18n="Button label"
>
Save
</button>
<button mat-button mat-dialog-close i18n="Button label">Cancel</button>
</mat-dialog-actions>
Loading
Loading