Skip to content

Commit

Permalink
Deletion of oauth clients (#188)
Browse files Browse the repository at this point in the history
* feat: add delete client command and respective handler

* feat: first draft of user interface to delete clients

* feat: add checkboxes for multiple client delete

* fix: use custom application manager to enable client deletion

* feat: parallel deletion of multiple clients added

* fix: send expected error message for concurrency exception as per openiddict docs

* fix: possible response types for deletion of clients

* refactor: promote custom open iddict application store to separate file

* refactor: change string interpolation in client service

* feat: add confirmation dialog for client deletion

* fix: remove unnecessary dependencies

* test: add integration tests for client deletion and fix project namespaces

* test: add "Integration" tag to integration tests for deletion of clients

* chore: cleanup CustomOpenIddictEntityFrameworkCoreApplicationStore

* test: add SetContent method to RequestConfiguration

* test: cleanup

---------

Co-authored-by: Timo Notheisen <[email protected]>
  • Loading branch information
daniel-almeida-konkconsulting and tnotheis authored Jul 6, 2023
1 parent bf18b33 commit 4c312ec
Show file tree
Hide file tree
Showing 54 changed files with 517 additions and 110 deletions.
1 change: 1 addition & 0 deletions AdminUi/src/AdminUi/AdminUi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="7.0.8" />
<PackageReference Include="Autofac.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="4.3.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.21.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
Expand Down
4 changes: 4 additions & 0 deletions AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { MatChipsModule } from '@angular/material/chips';
import { MatCheckboxModule } from '@angular/material/checkbox';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
Expand All @@ -39,6 +40,7 @@ import { TierEditComponent } from './components/quotas/tier/tier-edit/tier-edit.
import { ClientListComponent } from './components/client/client-list/client-list.component';
import { ClientEditComponent } from './components/client/client-edit/client-edit.component';
import { AssignQuotasDialogComponent } from './components/quotas/assign-quotas-dialog/assign-quotas-dialog.component';
import { ConfirmationDialogComponent } from './components/shared/confirmation-dialog/confirmation-dialog.component';

@NgModule({
declarations: [
Expand All @@ -53,6 +55,7 @@ import { AssignQuotasDialogComponent } from './components/quotas/assign-quotas-d
ClientListComponent,
ClientEditComponent,
AssignQuotasDialogComponent,
ConfirmationDialogComponent,
],
imports: [
FormsModule,
Expand All @@ -66,6 +69,7 @@ import { AssignQuotasDialogComponent } from './components/quotas/assign-quotas-d
MatButtonModule,
MatIconModule,
MatSidenavModule,
MatCheckboxModule,
MatListModule,
MatGridListModule,
MatTableModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@
align-items: center;
justify-content: center;
width: 100%;
background-color: #c1c10f;
}

.client-secret-warning {
color: red;
color: #ffffff;
font-weight: 500;
padding: 10px;
}

.form-details {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export class ClientEditComponent {
this.client = data.result;
}
this.displayClientSecretWarning = true;
this.disabled = true;
this.snackBar.open('Successfully added client.', 'Dismiss', {
duration: 4000,
verticalPosition: 'top',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,28 +9,46 @@ <h2 class="header-title ">{{ header }}</h2>
</mat-progress-spinner>
</div>
<div class="action-buttons">
<button mat-mini-fab color="warn" (click)="openConfirmationDialog()" [disabled]="selection.selected.length === 0" style="margin-right: 10px;">
<mat-icon >delete</mat-icon>
</button>
<button mat-mini-fab color="primary" (click)="addClient()">
<mat-icon>add</mat-icon>
</button>
</div>
<table mat-table class="responsive" [dataSource]="clients" *ngIf="!loading">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? toggleAllRows() : null"
[checked]="selection.hasValue() && isAllSelected()"
color="primary"
[indeterminate]="selection.hasValue() && !isAllSelected()"
[aria-label]="checkboxLabel()">
</mat-checkbox>
</th>
<td mat-cell *matCellDef="let row; let i = index;">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)"
color="primary"
[aria-label]="checkboxLabel(i, row)">
</mat-checkbox>
</td>
</ng-container>
<ng-container matColumnDef="clientId">
<th mat-header-cell *matHeaderCellDef>Client Id</th>
<td mat-cell *matCellDef="let client" data-label="clientId">
{{ client.clientId }}
</td>
</ng-container>

<ng-container matColumnDef="displayName">
<th mat-header-cell *matHeaderCellDef>Display Name</th>
<td mat-cell *matCellDef="let client" data-label="displayname">
{{ client.displayName }}
</td>
</ng-container>

<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>

<tr class="mat-row" *matNoDataRow>
<td class="mat-cell no-data" [attr.colspan]="displayedColumns.length">
No clients found.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { Component, ViewChild } from '@angular/core';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTableDataSource } from '@angular/material/table';
import { SelectionModel } from '@angular/cdk/collections';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import {
Client,
ClientDTO,
ClientServiceService,
} from 'src/app/services/client-service/client-service';
import { PagedHttpResponseEnvelope } from 'src/app/utils/paged-http-response-envelope';
import { forkJoin, Observable } from 'rxjs';
import { ConfirmationDialogComponent } from '../../shared/confirmation-dialog/confirmation-dialog.component';
@Component({
selector: 'app-client-list',
templateUrl: './client-list.component.html',
Expand All @@ -24,13 +28,16 @@ export class ClientListComponent {
pageSize: number;
pageIndex: number;
loading = false;
selection = new SelectionModel<ClientDTO>(true, []);
displayedColumns: string[] = [
'select',
'clientId',
'displayName'
];

constructor(
private router: Router,
private dialog: MatDialog,
private snackBar: MatSnackBar,
private clientService: ClientServiceService
) {
Expand All @@ -49,6 +56,7 @@ export class ClientListComponent {

getPagedData() {
this.loading = true;
this.selection = new SelectionModel<ClientDTO>(true, []);
this.clientService
.getClients(this.pageIndex, this.pageSize)
.subscribe({
Expand All @@ -74,17 +82,84 @@ export class ClientListComponent {
});
}

pageChangeEvent(event: PageEvent) {
pageChangeEvent(event: PageEvent): void {
this.pageIndex = event.pageIndex;
this.pageSize = event.pageSize;
this.getPagedData();
}

dateConvert(date: any) {
dateConvert(date: any): string {
return new Date(date).toLocaleDateString();
}

addClient() {
addClient(): void {
this.router.navigate([`/clients/create`]);
}

openConfirmationDialog() {
let confirmDialogHeader = this.selection.selected.length > 1 ? 'Delete Clients' : 'Delete Client';
let confirmDialogMessage = this.selection.selected.length > 1 ? `Are you sure you want to delete the ${this.selection.selected.length} selected clients?` : 'Are you sure you want to delete the selected client?';
let dialogRef = this.dialog.open(ConfirmationDialogComponent, {
minWidth: '40%',
disableClose: true,
data: { header: confirmDialogHeader, message: confirmDialogMessage },
});

dialogRef.afterClosed().subscribe((result: boolean) => {
if (result) {
this.deleteClient();
}
});
}

deleteClient(): void {
this.loading = true;
let observableBatch: Observable<any>[] = [];
this.selection.selected.forEach(item => {
observableBatch.push(this.clientService.deleteClient(item.clientId));
});

forkJoin(observableBatch)
.subscribe({
next: (_: any) => {
let successMessage: string = this.selection.selected.length > 1 ? `Successfully deleted ${this.selection.selected.length} clients.` : 'Successfully deleted 1 client.';
this.getPagedData();
this.snackBar.open(successMessage, 'Dismiss', {
duration: 4000,
verticalPosition: 'top',
horizontalPosition: 'center'
});
},
error: (err: any) => {
this.loading = false;
let errorMessage = (err.error && err.error.error && err.error.error.message) ? err.error.error.message : err.message;
this.snackBar.open(errorMessage, 'Dismiss', {
verticalPosition: 'top',
horizontalPosition: 'center'
});
},
});
}

isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.clients.length;
return numSelected === numRows;
}

toggleAllRows() {
if (this.isAllSelected()) {
this.selection.clear();
return;
}

this.selection.select(...this.clients);
}

checkboxLabel(index?: number, row?: ClientDTO): string {
if (!row || !index) {
return `${this.isAllSelected() ? 'deselect' : 'select'} all`;
}
return `${this.selection.isSelected(row) ? 'deselect' : 'select'} row ${index + 1}`;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.confirmation-title {
background-color: #673ab7;
color: #ffffff;
}

.confirmation-message {
color: #808080;
font-weight: 300;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<h2 mat-dialog-title class="confirmation-title">{{ data.header }}</h2>
<mat-dialog-content>
<h2 class="confirmation-message">{{ data.message }}</h2>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-flat-button [mat-dialog-close]="false">Cancel</button>
<button mat-flat-button color="primary" [mat-dialog-close]="true" cdkFocusInitial>Yes</button>
</mat-dialog-actions>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ConfirmationDialogComponent } from './confirmation-dialog.component';

describe('ConfirmationDialogComponent', () => {
let component: ConfirmationDialogComponent;
let fixture: ComponentFixture<ConfirmationDialogComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ ConfirmationDialogComponent ]
})
.compileComponents();

fixture = TestBed.createComponent(ConfirmationDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';

@Component({
selector: 'app-confirmation-dialog',
templateUrl: './confirmation-dialog.component.html',
styleUrls: ['./confirmation-dialog.component.css']
})
export class ConfirmationDialogComponent {
constructor(public dialogRef: MatDialogRef<ConfirmationDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: DialogData) { }
}

export interface DialogData {
header: string;
message: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export class ClientServiceService {
createClient(client: Client): Observable<HttpResponseEnvelope<Client>> {
return this.http.post<HttpResponseEnvelope<Client>>(this.apiUrl, client);
}

deleteClient(clientId: string): Observable<any> {
return this.http.delete<HttpResponseEnvelope<any>>(`${this.apiUrl}/${clientId}`);
}
}

export interface ClientDTO {
Expand Down
10 changes: 10 additions & 0 deletions AdminUi/src/AdminUi/Controllers/ClientsController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Backbone.Modules.Devices.Application.Clients.Commands.CreateClients;
using Backbone.Modules.Devices.Application.Clients.Commands.DeleteClient;
using Backbone.Modules.Devices.Application.Clients.Queries.ListClients;
using Enmeshed.BuildingBlocks.API;
using Enmeshed.BuildingBlocks.API.Mvc;
Expand Down Expand Up @@ -28,5 +29,14 @@ public async Task<CreatedResult> CreateOAuthClients(CreateClientCommand command,
var createdClient = await _mediator.Send(command, cancellationToken);
return Created(createdClient);
}

[HttpDelete("{clientId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteClient([FromRoute] string clientId, CancellationToken cancellationToken)
{
await _mediator.Send(new DeleteClientCommand(clientId), cancellationToken);
return NoContent();
}
}

Loading

0 comments on commit 4c312ec

Please sign in to comment.