diff --git a/AdminUi/src/AdminUi/AdminUi.csproj b/AdminUi/src/AdminUi/AdminUi.csproj index dd546bc763..5fffa02637 100644 --- a/AdminUi/src/AdminUi/AdminUi.csproj +++ b/AdminUi/src/AdminUi/AdminUi.csproj @@ -13,6 +13,8 @@ + + all diff --git a/AdminUi/src/AdminUi/ClientApp/package-lock.json b/AdminUi/src/AdminUi/ClientApp/package-lock.json index 72ec7226e8..ca27f4b3be 100644 --- a/AdminUi/src/AdminUi/ClientApp/package-lock.json +++ b/AdminUi/src/AdminUi/ClientApp/package-lock.json @@ -24,6 +24,7 @@ "bootstrap": "^5.2.3", "jquery": "^3.6.3", "ngx-logger": "^5.0.12", + "odata-filter-builder": "^1.0.0", "oidc-client": "^1.11.5", "popper.js": "^1.16.0", "run-script-os": "^1.1.6", @@ -11185,6 +11186,11 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "dev": true }, + "node_modules/odata-filter-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/odata-filter-builder/-/odata-filter-builder-1.0.0.tgz", + "integrity": "sha512-Yifh36pkANQq0Y4HJOHIDYGjraSx1iRcAP5Qg7bUhoKTDvaxy2XnluvV1VpkbIWP5uRgJi+4FPZydjTPX0baDQ==" + }, "node_modules/oidc-client": { "version": "1.11.5", "resolved": "https://registry.npmjs.org/oidc-client/-/oidc-client-1.11.5.tgz", diff --git a/AdminUi/src/AdminUi/ClientApp/package.json b/AdminUi/src/AdminUi/ClientApp/package.json index 0674ea538e..e9d60eec52 100644 --- a/AdminUi/src/AdminUi/ClientApp/package.json +++ b/AdminUi/src/AdminUi/ClientApp/package.json @@ -27,6 +27,7 @@ "bootstrap": "^5.2.3", "jquery": "^3.6.3", "ngx-logger": "^5.0.12", + "odata-filter-builder": "^1.0.0", "oidc-client": "^1.11.5", "popper.js": "^1.16.0", "run-script-os": "^1.1.6", diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/app.component.css b/AdminUi/src/AdminUi/ClientApp/src/app/app.component.css index b45602cb73..dc47cb6654 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/app.component.css +++ b/AdminUi/src/AdminUi/ClientApp/src/app/app.component.css @@ -8,8 +8,9 @@ .layout-content { padding: 1rem; - max-width: 1350px; + min-width: 1350px; margin: auto; + width: fit-content; } .login-layout { diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts b/AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts index e2371c37d0..7b919cad6c 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/app.module.ts @@ -13,6 +13,8 @@ import { MatButtonModule } from "@angular/material/button"; import { MatCardModule } from "@angular/material/card"; import { MatCheckboxModule } from "@angular/material/checkbox"; import { MatChipsModule } from "@angular/material/chips"; +import { MatNativeDateModule } from "@angular/material/core"; +import { MatDatepickerModule } from "@angular/material/datepicker"; import { MatDialogModule } from "@angular/material/dialog"; import { MatExpansionModule } from "@angular/material/expansion"; import { MatFormFieldModule } from "@angular/material/form-field"; @@ -29,6 +31,7 @@ import { MatTableModule } from "@angular/material/table"; import { MatToolbarModule } from "@angular/material/toolbar"; import { MatTooltipModule } from "@angular/material/tooltip"; +import { MatSortModule } from "@angular/material/sort"; import { environment } from "src/environments/environment"; import { AppRoutingModule } from "./app-routing.module"; import { AppComponent } from "./app.component"; @@ -103,7 +106,10 @@ import { XSRFInterceptor } from "./shared/interceptors/xsrf.interceptor"; MatButtonModule, MatIconModule, MatSidenavModule, + MatDatepickerModule, + MatNativeDateModule, MatCheckboxModule, + MatSortModule, MatListModule, MatGridListModule, MatTableModule, diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/change-secret-dialog/change-secret-dialog.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/change-secret-dialog/change-secret-dialog.component.ts index 359a5104bd..ca797e03e6 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/change-secret-dialog/change-secret-dialog.component.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/change-secret-dialog/change-secret-dialog.component.ts @@ -1,7 +1,7 @@ import { Component, Inject } from "@angular/core"; -import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; +import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; import { MatSnackBar } from "@angular/material/snack-bar"; -import { ChangeClientSecretRequest, Client, ClientServiceService } from "src/app/services/client-service/client-service"; +import { ChangeClientSecretRequest, Client, ClientService } from "src/app/services/client-service/client-service"; import { HttpResponseEnvelope } from "src/app/utils/http-response-envelope"; @Component({ @@ -20,7 +20,7 @@ export class ChangeSecretDialogComponent { public constructor( private readonly snackBar: MatSnackBar, - private readonly clientService: ClientServiceService, + private readonly clientService: ClientService, public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: any ) { diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.ts index 80a5704eda..b623d9f490 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-edit/client-edit.component.ts @@ -1,7 +1,7 @@ import { Component } from "@angular/core"; import { MatSnackBar } from "@angular/material/snack-bar"; import { ActivatedRoute } from "@angular/router"; -import { Client, UpdateClientRequest, ClientServiceService } from "src/app/services/client-service/client-service"; +import { Client, ClientService, UpdateClientRequest } from "src/app/services/client-service/client-service"; import { TierOverview, TierService } from "src/app/services/tier-service/tier.service"; import { HttpResponseEnvelope } from "src/app/utils/http-response-envelope"; import { PagedHttpResponseEnvelope } from "src/app/utils/paged-http-response-envelope"; @@ -28,7 +28,7 @@ export class ClientEditComponent { public constructor( private readonly route: ActivatedRoute, private readonly snackBar: MatSnackBar, - private readonly clientService: ClientServiceService, + private readonly clientService: ClientService, private readonly tierService: TierService ) { this.headerCreate = "Create Client"; diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.css b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.css index 95a28b24cf..50cf53c741 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.css +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.css @@ -10,22 +10,23 @@ } .mat-column-numberOfIdentities { - width: 10%; + max-width: 200px; word-break: normal; } .mat-column-createdAt { - width: 15%; + max-width: 370px; word-break: normal; } .mat-column-actions { - width: 20%; + max-width: 300px; word-break: normal; } .actions-button { min-height: 36px; + min-width: 200px; height: auto; } @@ -39,6 +40,10 @@ color: blue; } +.inline-action-buttons ::ng-deep .mat-mdc-form-field-icon-suffix { + display: inherit; +} + .loading { display: flex; justify-content: center; @@ -50,6 +55,18 @@ height: 100%; } +.complex-filter-container { + display: flex; + flex-direction: row; + justify-content: space-between; + padding-top: 10px; +} + +.header-container { + display: flex; + flex-direction: column; +} + .no-data { padding: 25px; } diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.html index c2ea0863d4..21f4bbc7fc 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.html @@ -15,7 +15,7 @@

{{ header }}

add - +
+ - + - + - - + - - + - + diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.ts index 670f8a69ad..87729091ff 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/client/client-list/client-list.component.ts @@ -1,12 +1,16 @@ +import { SelectionModel } from "@angular/cdk/collections"; import { Component, ViewChild } from "@angular/core"; -import { MatPaginator, PageEvent } from "@angular/material/paginator"; +import { MatDialog } from "@angular/material/dialog"; +import { MatPaginator } from "@angular/material/paginator"; import { MatSnackBar } from "@angular/material/snack-bar"; -import { SelectionModel } from "@angular/cdk/collections"; +import { Sort } from "@angular/material/sort"; +import { MatTable } from "@angular/material/table"; import { Router } from "@angular/router"; -import { MatDialog } from "@angular/material/dialog"; -import { ClientOverview, ClientServiceService } from "src/app/services/client-service/client-service"; +import { NGXLogger } from "ngx-logger"; +import { Observable, forkJoin } from "rxjs"; +import { ClientOverview, ClientOverviewFilter, ClientService } from "src/app/services/client-service/client-service"; +import { TierOverview, TierService } from "src/app/services/tier-service/tier.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"; import { ChangeSecretDialogComponent } from "../change-secret-dialog/change-secret-dialog.component"; @Component({ @@ -16,46 +20,64 @@ import { ChangeSecretDialogComponent } from "../change-secret-dialog/change-secr }) export class ClientListComponent { @ViewChild(MatPaginator) public paginator!: MatPaginator; + @ViewChild(MatTable) public table!: MatTable; public header: string; public headerDescription: string; public clients: ClientOverview[]; - public totalRecords: number; - public pageSize: number; - public pageIndex: number; + public serverClients: ClientOverview[]; public loading = false; + public filter: ClientOverviewFilter; + public tiers: TierOverview[]; public selection = new SelectionModel(true, []); + public operators: string[] = ["=", "<", ">", ">=", "<="]; public displayedColumns: string[] = ["select", "clientId", "displayName", "defaultTier", "numberOfIdentities", "createdAt", "actions"]; public constructor( private readonly router: Router, private readonly dialog: MatDialog, private readonly snackBar: MatSnackBar, - private readonly clientService: ClientServiceService + private readonly tierService: TierService, + private readonly clientService: ClientService, + private readonly logger: NGXLogger ) { this.header = "Clients"; this.headerDescription = "A list of existing Clients"; this.clients = []; - this.totalRecords = 0; - this.pageSize = 10; - this.pageIndex = 0; + this.tiers = []; + this.serverClients = []; + this.filter = { createdAt: { operator: "=" }, numberOfIdentities: { operator: "=" } }; this.loading = true; } public ngOnInit(): void { this.getPagedData(); + this.getTiers(); + } + + public getTiers(): void { + this.tierService.getTiers().subscribe({ + next: (data: PagedHttpResponseEnvelope) => { + this.tiers = data.result; + }, + complete: () => (this.loading = false), + error: (err: any) => { + this.loading = false; + const errorMessage = err.error?.error?.message ?? err.message; + this.snackBar.open(errorMessage, "Dismiss", { + verticalPosition: "top", + horizontalPosition: "center" + }); + } + }); } public getPagedData(): void { this.loading = true; this.selection = new SelectionModel(true, []); - this.clientService.getClients(this.pageIndex, this.pageSize).subscribe({ + this.clientService.getClients().subscribe({ next: (data: PagedHttpResponseEnvelope) => { this.clients = data.result; - if (data.pagination) { - this.totalRecords = data.pagination.totalRecords!; - } else { - this.totalRecords = data.result.length; - } + this.serverClients = data.result; }, complete: () => (this.loading = false), error: (err: any) => { @@ -69,14 +91,98 @@ export class ClientListComponent { }); } - public pageChangeEvent(event: PageEvent): void { - this.pageIndex = event.pageIndex; - this.pageSize = event.pageSize; - this.getPagedData(); + public filterClients(): void { + this.loading = true; + + this.clients = this.serverClients; + if (this.filter.clientId) this.clients = this.clients.filter((c) => c.clientId.toUpperCase().includes(this.filter.clientId!.toUpperCase())); + if (this.filter.displayName) this.clients = this.clients.filter((c) => c.displayName.toUpperCase().includes(this.filter.displayName!.toUpperCase())); + if (this.filter.tiers && this.filter.tiers.length > 0) this.clients = this.clients.filter((c) => this.filter.tiers!.includes(c.defaultTier)); + if (this.filter.numberOfIdentities.value !== undefined) { + switch (this.filter.numberOfIdentities.operator) { + case "=": + this.clients = this.clients.filter((c) => c.numberOfIdentities === this.filter.numberOfIdentities.value!); + break; + case ">": + this.clients = this.clients.filter((c) => c.numberOfIdentities > this.filter.numberOfIdentities.value!); + break; + case "<": + this.clients = this.clients.filter((c) => c.numberOfIdentities < this.filter.numberOfIdentities.value!); + break; + case ">=": + this.clients = this.clients.filter((c) => c.numberOfIdentities >= this.filter.numberOfIdentities.value!); + break; + case "<=": + this.clients = this.clients.filter((c) => c.numberOfIdentities <= this.filter.numberOfIdentities.value!); + break; + default: + this.logger.error(`Invalid numberOfIdentities filter operator: ${this.filter.numberOfIdentities.operator}`); + break; + } + } + if (this.filter.createdAt.value !== undefined) { + switch (this.filter.createdAt.operator) { + case "=": + this.clients = this.clients.filter((c) => new Date(c.createdAt).toISOString() === this.filter.createdAt.value!.toISOString()); + break; + case ">": + this.clients = this.clients.filter((c) => new Date(c.createdAt).toISOString() > this.filter.createdAt.value!.toISOString()); + break; + case "<": + this.clients = this.clients.filter((c) => new Date(c.createdAt).toISOString() < this.filter.createdAt.value!.toISOString()); + break; + case ">=": + this.clients = this.clients.filter((c) => new Date(c.createdAt).toISOString() >= this.filter.createdAt.value!.toISOString()); + break; + case "<=": + this.clients = this.clients.filter((c) => new Date(c.createdAt).toISOString() <= this.filter.createdAt.value!.toISOString()); + break; + default: + this.logger.error(`Invalid createdAt filter operator: ${this.filter.createdAt.operator}`); + break; + } + } + this.loading = false; + } + + public clearFilter(filter: string): void { + switch (filter) { + case "clientId": + this.filter.clientId = ""; + break; + case "displayName": + this.filter.displayName = ""; + break; + case "createdAt": + this.filter.createdAt.value = undefined; + break; + default: + this.logger.error(`Invalid filter: ${filter}`); + break; + } + + this.filterClients(); } - public dateConvert(date: any): string { - return new Date(date).toLocaleDateString(); + public onTableSort(sort: Sort): void { + switch (sort.active) { + case "clientId": + if (sort.direction.toString() === "desc") { + this.clients.sort((a, b) => a.clientId.toLowerCase().localeCompare(b.clientId.toLowerCase())); + } else { + this.clients.sort((a, b) => a.clientId.toLowerCase().localeCompare(b.clientId.toLowerCase())).reverse(); + } + break; + case "displayName": + if (sort.direction.toString() === "desc") { + this.clients.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())); + } else { + this.clients.sort((a, b) => a.displayName.toLowerCase().localeCompare(b.displayName.toLowerCase())).reverse(); + } + break; + } + + this.table.renderRows(); } public async addClient(): Promise { diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.css b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.css index fadcf4a23c..f54fd74d0b 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.css +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.css @@ -1,15 +1,43 @@ .mat-column-address, +.mat-column-address-filter, .mat-column-clientId, .mat-column-publicKey { width: 25%; word-break: break-all; } +.mat-column-createdAt, +.mat-column-created-at-filter, +.mat-column-last-login-filter, +.mat-column-lastLoginAt { + max-width: 330px; +} + +.mat-column-tier-filter, +.mat-column-tierName, +.mat-column-createdWithClient, +.mat-column-client-filter { + max-width: 230px; +} + +.mat-column-number-of-devices-filter, +.mat-column-numberOfDevices, +.mat-column-identity-version-filter, +.mat-column-identityVersion, +.mat-column-datawallet-version-filter, +.mat-column-datawalletVersion { + max-width: 200px; +} + .disabled-container { pointer-events: none; opacity: 0.4; } +.inline-action-buttons ::ng-deep .mat-mdc-form-field-icon-suffix { + display: inherit; +} + .mat-column-address { width: 26.5em; } @@ -48,6 +76,11 @@ margin: 15px 15px -50px 15px; } +.filter-cell { + display: flex; + justify-content: space-between; +} + .header-description { color: #fff; } diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.html index a2a1982c1f..db6eabb27e 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.html @@ -2,8 +2,8 @@

{{ header }}

{{ headerDescription }}

- - + +
@@ -54,7 +54,120 @@

{{ header }}

{{ identity.identityVersion }} + +
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.ts index c361dbdecd..7f942f241a 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/identity/identity-list/identity-list.component.ts @@ -1,8 +1,13 @@ -import { Component, ViewChild } from "@angular/core"; +import { Component, ElementRef, ViewChild } from "@angular/core"; import { MatPaginator, PageEvent } from "@angular/material/paginator"; import { MatSnackBar } from "@angular/material/snack-bar"; import { Router } from "@angular/router"; -import { IdentityOverview, IdentityService } from "src/app/services/identity-service/identity.service"; +import { NGXLogger } from "ngx-logger"; +import { debounceTime, distinctUntilChanged, filter, fromEvent, tap } from "rxjs"; +import { ClientOverview, ClientService } from "src/app/services/client-service/client-service"; +import { IdentityOverview, IdentityOverviewFilter, IdentityService } from "src/app/services/identity-service/identity.service"; +import { TierOverview, TierService } from "src/app/services/tier-service/tier.service"; +import { ODataResponse } from "src/app/utils/odata-response"; import { PagedHttpResponseEnvelope } from "src/app/utils/paged-http-response-envelope"; @Component({ @@ -12,6 +17,18 @@ import { PagedHttpResponseEnvelope } from "src/app/utils/paged-http-response-env }) export class IdentityListComponent { @ViewChild(MatPaginator) public paginator!: MatPaginator; + @ViewChild("addressFilter", { static: false }) public set addressFilter(input: ElementRef | undefined) { + this.debounceFilter(input, "address"); + } + @ViewChild("numberOfDevicesFilter", { static: false }) public set numberOfDevicesFilter(input: ElementRef | undefined) { + this.debounceFilter(input, "numberOfDevices"); + } + @ViewChild("datawalletVersionFilter", { static: false }) public set datawalletVersionFilter(input: ElementRef | undefined) { + this.debounceFilter(input, "datawalletVersion"); + } + @ViewChild("identityVersionFilter", { static: false }) public set identityVersionFilter(input: ElementRef | undefined) { + this.debounceFilter(input, "identityVersion"); + } public header: string; public headerDescription: string; @@ -25,11 +42,29 @@ export class IdentityListComponent { public loading = false; public displayedColumns: string[] = ["address", "tierName", "createdWithClient", "numberOfDevices", "createdAt", "lastLoginAt", "datawalletVersion", "identityVersion"]; + public displayedColumnFilters: string[] = [ + "address-filter", + "tier-filter", + "client-filter", + "number-of-devices-filter", + "created-at-filter", + "last-login-filter", + "datawallet-version-filter", + "identity-version-filter" + ]; + public operators: string[] = ["=", "<", ">", ">=", "<="]; + + public filter: IdentityOverviewFilter; + public tiers: TierOverview[]; + public clients: ClientOverview[]; public constructor( private readonly router: Router, private readonly snackBar: MatSnackBar, - private readonly identityService: IdentityService + private readonly identityService: IdentityService, + private readonly tierService: TierService, + private readonly clientService: ClientService, + private readonly logger: NGXLogger ) { this.header = "Identities"; this.headerDescription = "A list of existing Identities"; @@ -40,23 +75,74 @@ export class IdentityListComponent { this.pageSize = 10; this.pageIndex = 0; + this.filter = { createdAt: { operator: "=" }, numberOfDevices: { operator: "=" }, identityVersion: { operator: "=" }, lastLoginAt: { operator: "=" }, datawalletVersion: { operator: "=" } }; + this.tiers = []; + this.clients = []; + this.loading = true; } public ngOnInit(): void { - this.getPagedData(); + this.getIdentities(); + this.getTiers(); + this.getClients(); + } + + private debounceFilter(filterElement: ElementRef | undefined, filterName: string): void { + if (filterElement !== undefined) { + fromEvent(filterElement.nativeElement, "keyup") + .pipe( + filter(Boolean), + debounceTime(750), + distinctUntilChanged(), + tap((_) => { + this.onFilterChange(filterName); + }) + ) + .subscribe(); + } + } + + private getTiers(): void { + this.tierService.getTiers().subscribe({ + next: (data: PagedHttpResponseEnvelope) => { + this.tiers = data.result; + }, + complete: () => (this.loading = false), + error: (err: any) => { + this.loading = false; + const errorMessage = err.error?.error?.message ?? err.message; + this.snackBar.open(errorMessage, "Dismiss", { + verticalPosition: "top", + horizontalPosition: "center" + }); + } + }); } - public getPagedData(): void { + private getClients(): void { + this.clientService.getClients().subscribe({ + next: (data: PagedHttpResponseEnvelope) => { + this.clients = data.result; + }, + complete: () => (this.loading = false), + error: (err: any) => { + this.loading = false; + const errorMessage = err.error?.error?.message ?? err.message; + this.snackBar.open(errorMessage, "Dismiss", { + verticalPosition: "top", + horizontalPosition: "center" + }); + } + }); + } + + private getIdentities(): void { this.loading = true; - this.identityService.getIdentities(this.pageIndex, this.pageSize).subscribe({ - next: (data: PagedHttpResponseEnvelope) => { - this.identities = data.result; - if (data.pagination) { - this.totalRecords = data.pagination.totalRecords!; - } else { - this.totalRecords = data.result.length; - } + this.identityService.getIdentities(this.filter, this.pageIndex, this.pageSize).subscribe({ + next: (data: ODataResponse) => { + this.identities = data.value; + this.totalRecords = data.value.length; }, complete: () => (this.loading = false), error: (err: any) => { @@ -73,7 +159,7 @@ export class IdentityListComponent { public pageChangeEvent(event: PageEvent): void { this.pageIndex = event.pageIndex; this.pageSize = event.pageSize; - this.getPagedData(); + this.getIdentities(); } public async editIdentity(identityAddress: string): Promise { @@ -83,4 +169,69 @@ export class IdentityListComponent { public async goToTier(tierId: string): Promise { await this.router.navigate([`/tiers/${tierId}`]); } + + public onFilterChange(filter: string): void { + switch (filter) { + case "address": + if (this.filter.address!.length > 2) this.getIdentities(); + break; + case "tiers": + case "clients": + this.getIdentities(); + break; + case "numberOfDevices": + if ( + (this.filter.numberOfDevices.operator !== undefined && this.filter.numberOfDevices.value !== undefined) || + (this.filter.numberOfDevices.operator !== undefined && this.filter.numberOfDevices.value === undefined) + ) { + this.getIdentities(); + } + break; + case "createdAt": + if (this.filter.createdAt.operator !== undefined && this.filter.createdAt.operator !== "" && this.filter.createdAt.value !== undefined) this.getIdentities(); + break; + case "lastLoginAt": + if (this.filter.lastLoginAt.operator !== undefined && this.filter.lastLoginAt.operator !== "" && this.filter.lastLoginAt.value !== undefined) this.getIdentities(); + break; + case "datawalletVersion": + if ( + (this.filter.datawalletVersion.operator !== undefined && this.filter.datawalletVersion.value !== undefined) || + (this.filter.datawalletVersion.operator !== undefined && this.filter.datawalletVersion.value === undefined) + ) { + this.getIdentities(); + } + break; + case "identityVersion": + if ( + (this.filter.identityVersion.operator !== undefined && this.filter.identityVersion.value !== undefined) || + (this.filter.identityVersion.operator !== undefined && this.filter.identityVersion.value === undefined) + ) { + this.getIdentities(); + } + break; + default: + this.logger.error(`OnFilterChange: Invalid filter name: ${filter}`); + break; + } + } + + public clearFilter(filter: string): void { + switch (filter) { + case "address": + this.filter.address = ""; + this.getIdentities(); + break; + case "createdAt": + this.filter.createdAt.value = undefined; + if (this.filter.createdAt.operator !== undefined) this.getIdentities(); + break; + case "lastLoginAt": + this.filter.lastLoginAt.value = undefined; + if (this.filter.lastLoginAt.operator !== undefined) this.getIdentities(); + break; + default: + this.logger.error(`ClearFilter: Invalid filter name: ${filter}`); + break; + } + } } diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.html b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.html index 2c36ec0978..2f41625501 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.html +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.html @@ -32,15 +32,5 @@

{{ header }}

{{ header }} - Client Id +
+ Client Id + + + + +
+
{{ client.clientId }} Display Name +
+ Display Name + + + + +
+
{{ client.displayName }} Default Tier +
+ Default Tier + + Tiers + + {{ tier.id }} + + +
+
{{ client.defaultTier }} Number of Identities + +
+ Number of Identities +
+ + + {{ operator }} + + + + + +
+
+
{{ client.numberOfIdentities }} Created At + +
+ Created At +
+ + + {{ operator }} + + + + Choose a date + + + + + +
+
+
{{ client.createdAt | date }} Actions + + + + + + + Tiers + + {{ tier.name }} + + + + + Clients + + {{ client.displayName }} + + + +
+ + + {{ operator }} + + + + + +
+
+
+ + + {{ operator }} + + + + Choose a date + + + + + +
+
+
+ + + {{ operator }} + + + + Choose a date + + + + + +
+
+
+ + + {{ operator }} + + + + + +
+
+
+ + + {{ operator }} + + + + + +
+
No identities found.No tiers found.
- - diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.ts b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.ts index e1126499ac..81c7d85bfc 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/components/quotas/tier/tier-list/tier-list.component.ts @@ -1,5 +1,5 @@ import { Component, ViewChild } from "@angular/core"; -import { MatPaginator, PageEvent } from "@angular/material/paginator"; +import { MatPaginator } from "@angular/material/paginator"; import { MatSnackBar } from "@angular/material/snack-bar"; import { Router } from "@angular/router"; import { Tier, TierOverview, TierService } from "src/app/services/tier-service/tier.service"; @@ -18,10 +18,6 @@ export class TierListComponent { public tiers: TierOverview[]; - public totalRecords: number; - public pageSize: number; - public pageIndex: number; - public loading = false; public displayedColumns: string[] = ["name", "numberOfIdentities"]; @@ -36,27 +32,18 @@ export class TierListComponent { this.tiers = []; - this.totalRecords = 0; - this.pageSize = 10; - this.pageIndex = 0; - this.loading = true; } public ngOnInit(): void { - this.getPagedData(); + this.getTiers(); } - public getPagedData(): void { + public getTiers(): void { this.loading = true; - this.tierService.getTiers(this.pageIndex, this.pageSize).subscribe({ + this.tierService.getTiers().subscribe({ next: (data: PagedHttpResponseEnvelope) => { this.tiers = data.result; - if (data.pagination) { - this.totalRecords = data.pagination.totalRecords!; - } else { - this.totalRecords = data.result.length; - } }, complete: () => (this.loading = false), error: (err: any) => { @@ -70,12 +57,6 @@ export class TierListComponent { }); } - public pageChangeEvent(event: PageEvent): void { - this.pageIndex = event.pageIndex; - this.pageSize = event.pageSize; - this.getPagedData(); - } - public async addTier(): Promise { await this.router.navigate(["/tiers/create"]); } diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/services/client-service/client-service.spec.ts b/AdminUi/src/AdminUi/ClientApp/src/app/services/client-service/client-service.spec.ts index b7f668128b..4df8cdc9ff 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/services/client-service/client-service.spec.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/services/client-service/client-service.spec.ts @@ -1,13 +1,13 @@ import { TestBed } from "@angular/core/testing"; -import { ClientServiceService } from "./client-service"; +import { ClientService } from "./client-service"; describe("ClientServiceService", function () { - let service: ClientServiceService; + let service: ClientService; beforeEach(function () { TestBed.configureTestingModule({}); - service = TestBed.inject(ClientServiceService); + service = TestBed.inject(ClientService); }); it("should be created", async function () { diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/services/client-service/client-service.ts b/AdminUi/src/AdminUi/ClientApp/src/app/services/client-service/client-service.ts index 665b162b65..ec07e724bf 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/services/client-service/client-service.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/services/client-service/client-service.ts @@ -1,14 +1,16 @@ -import { HttpClient, HttpParams } from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; +import { DateFilter } from "src/app/utils/date-filter"; import { HttpResponseEnvelope } from "src/app/utils/http-response-envelope"; +import { NumberFilter } from "src/app/utils/number-filter"; import { PagedHttpResponseEnvelope } from "src/app/utils/paged-http-response-envelope"; import { environment } from "src/environments/environment"; @Injectable({ providedIn: "root" }) -export class ClientServiceService { +export class ClientService { private readonly apiUrl: string; public constructor(private readonly http: HttpClient) { this.apiUrl = `${environment.apiUrl}/Clients`; @@ -18,12 +20,8 @@ export class ClientServiceService { return this.http.get>(`${this.apiUrl}/${id}`); } - public getClients(pageNumber: number, pageSize: number): Observable> { - const httpOptions = { - params: new HttpParams().set("PageNumber", pageNumber + 1).set("PageSize", pageSize) - }; - - return this.http.get>(this.apiUrl, httpOptions); + public getClients(): Observable> { + return this.http.get>(this.apiUrl); } public createClient(client: Client): Observable> { @@ -45,7 +43,7 @@ export class ClientServiceService { export interface ClientOverview { clientId: string; - displayName?: string; + displayName: string; defaultTier: string; createdAt: Date; numberOfIdentities: number; @@ -66,3 +64,11 @@ export interface ChangeClientSecretRequest { export interface UpdateClientRequest { defaultTier: string; } + +export interface ClientOverviewFilter { + clientId?: string; + tiers?: string[]; + displayName?: string; + numberOfIdentities: NumberFilter; + createdAt: DateFilter; +} diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/services/identity-service/identity.service.ts b/AdminUi/src/AdminUi/ClientApp/src/app/services/identity-service/identity.service.ts index bf1b431cd2..0371684b23 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/services/identity-service/identity.service.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/services/identity-service/identity.service.ts @@ -1,8 +1,12 @@ -import { HttpClient, HttpParams } from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; import { Injectable } from "@angular/core"; +import { NGXLogger } from "ngx-logger"; +import ODataFilterBuilder from "odata-filter-builder"; import { Observable } from "rxjs"; +import { DateFilter } from "src/app/utils/date-filter"; import { HttpResponseEnvelope } from "src/app/utils/http-response-envelope"; -import { PagedHttpResponseEnvelope } from "src/app/utils/paged-http-response-envelope"; +import { NumberFilter } from "src/app/utils/number-filter"; +import { ODataResponse } from "src/app/utils/odata-response"; import { environment } from "src/environments/environment"; import { Quota } from "../quotas-service/quotas.service"; @@ -11,23 +15,167 @@ import { Quota } from "../quotas-service/quotas.service"; }) export class IdentityService { private readonly apiUrl: string; + private readonly odataUrl: string; - public constructor(private readonly http: HttpClient) { + public constructor( + private readonly http: HttpClient, + private readonly logger: NGXLogger + ) { this.apiUrl = `${environment.apiUrl}/Identities`; + this.odataUrl = `${environment.odataUrl}/Identities`; } - public getIdentities(pageNumber: number, pageSize: number): Observable> { - const httpOptions = { - params: new HttpParams().set("PageNumber", pageNumber + 1).set("PageSize", pageSize) - }; - - return this.http.get>(this.apiUrl, httpOptions); + public getIdentities(filter: IdentityOverviewFilter, pageNumber: number, pageSize: number): Observable> { + const paginationFilter = `$top=${pageSize}&$skip=${pageNumber}`; + return this.http.get>(`${this.odataUrl}${this.buildODataFilter(filter, paginationFilter)}`); } public getIdentityByAddress(address: string): Observable> { return this.http.get>(`${this.apiUrl}/${address}`); } + private buildODataFilter(filter: IdentityOverviewFilter, paginationFilter: string): string { + const odataFilter = ODataFilterBuilder(); + + if (filter.address !== undefined && filter.address !== "") odataFilter.contains("address", filter.address); + + if (filter.tiers !== undefined && filter.tiers.length > 0) { + const tiersFilter = new ODataFilterBuilder(); + filter.tiers.forEach((tier) => { + tiersFilter.or((x) => x.eq("tierId", tier)); + }); + odataFilter.and((_) => tiersFilter); + } + + if (filter.clients !== undefined && filter.clients.length > 0) { + const clientsFilter = new ODataFilterBuilder(); + filter.clients.forEach((client) => { + clientsFilter.or((x) => x.eq("createdWithClient", client)); + }); + odataFilter.and((_) => clientsFilter); + } + + if (filter.createdAt.operator !== undefined && filter.createdAt.value !== undefined) { + switch (filter.createdAt.operator) { + case ">": + odataFilter.gt("createdAt", filter.createdAt.value); + break; + case "<": + odataFilter.lt("createdAt", filter.createdAt.value); + break; + case "=": + odataFilter.eq("createdAt", filter.createdAt.value); + break; + case "<=": + odataFilter.le("createdAt", filter.createdAt.value); + break; + case ">=": + odataFilter.ge("createdAt", filter.createdAt.value); + break; + default: + this.logger.error(`Invalid createdAt filter operator: ${filter.createdAt.operator}`); + break; + } + } + + if (filter.lastLoginAt.operator !== undefined && filter.lastLoginAt.value !== undefined) { + switch (filter.lastLoginAt.operator) { + case ">": + odataFilter.gt("lastLoginAt", filter.lastLoginAt.value); + break; + case "<": + odataFilter.lt("lastLoginAt", filter.lastLoginAt.value); + break; + case "=": + odataFilter.eq("lastLoginAt", filter.lastLoginAt.value); + break; + case "<=": + odataFilter.le("lastLoginAt", filter.lastLoginAt.value); + break; + case ">=": + odataFilter.ge("lastLoginAt", filter.lastLoginAt.value); + break; + default: + this.logger.error(`Invalid lastLoginAt filter operator: ${filter.lastLoginAt.operator}`); + break; + } + } + + if (filter.numberOfDevices.operator !== undefined && filter.numberOfDevices.value?.valueOf) { + switch (filter.numberOfDevices.operator) { + case ">": + odataFilter.gt("numberOfDevices", filter.numberOfDevices.value); + break; + case "<": + odataFilter.lt("numberOfDevices", filter.numberOfDevices.value); + break; + case "=": + odataFilter.eq("numberOfDevices", filter.numberOfDevices.value); + break; + case "<=": + odataFilter.le("numberOfDevices", filter.numberOfDevices.value); + break; + case ">=": + odataFilter.ge("numberOfDevices", filter.numberOfDevices.value); + break; + default: + this.logger.error(`Invalid numberOfDevices filter operator: ${filter.numberOfDevices.operator}`); + break; + } + } + + if (filter.datawalletVersion.operator !== undefined && filter.datawalletVersion.value?.valueOf) { + switch (filter.datawalletVersion.operator) { + case ">": + odataFilter.gt("datawalletVersion", filter.datawalletVersion.value); + break; + case "<": + odataFilter.lt("datawalletVersion", filter.datawalletVersion.value); + break; + case "=": + odataFilter.eq("datawalletVersion", filter.datawalletVersion.value); + break; + case "<=": + odataFilter.le("datawalletVersion", filter.datawalletVersion.value); + break; + case ">=": + odataFilter.ge("datawalletVersion", filter.datawalletVersion.value); + break; + default: + this.logger.error(`Invalid datawalletVersion filter operator: ${filter.datawalletVersion.operator}`); + break; + } + } + + if (filter.identityVersion.operator !== undefined && filter.identityVersion.value?.valueOf) { + switch (filter.identityVersion.operator) { + case ">": + odataFilter.gt("identityVersion", filter.identityVersion.value); + break; + case "<": + odataFilter.lt("identityVersion", filter.identityVersion.value); + break; + case "=": + odataFilter.eq("identityVersion", filter.identityVersion.value); + break; + case "<=": + odataFilter.le("identityVersion", filter.identityVersion.value); + break; + case ">=": + odataFilter.ge("identityVersion", filter.identityVersion.value); + break; + default: + this.logger.error(`Invalid identityVersion filter operator: ${filter.identityVersion.operator}`); + break; + } + } + + const filterComponents = [odataFilter.toString() !== "" ? `$filter=${odataFilter.toString()}` : "", paginationFilter]; + const filterParameter = `?${filterComponents.join("&")}`; + + return filterParameter; + } + public updateIdentity(identity: Identity, params: UpdateTierRequest): Observable> { return this.http.put>(`${this.apiUrl}/${identity.address}`, params); } @@ -68,6 +216,17 @@ export interface IdentityOverview { numberOfDevices: number; } +export interface IdentityOverviewFilter { + address?: string; + tiers?: string[]; + clients?: string[]; + numberOfDevices: NumberFilter; + createdAt: DateFilter; + lastLoginAt: DateFilter; + datawalletVersion: NumberFilter; + identityVersion: NumberFilter; +} + export interface UpdateTierRequest { tierId: string; } diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/services/tier-service/tier.service.ts b/AdminUi/src/AdminUi/ClientApp/src/app/services/tier-service/tier.service.ts index 0dc3ed5272..941ec8f109 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/app/services/tier-service/tier.service.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/app/services/tier-service/tier.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpParams } from "@angular/common/http"; +import { HttpClient } from "@angular/common/http"; import { Injectable } from "@angular/core"; import { Observable } from "rxjs"; import { HttpResponseEnvelope } from "src/app/utils/http-response-envelope"; @@ -16,14 +16,8 @@ export class TierService { this.apiUrl = `${environment.apiUrl}/Tiers`; } - public getTiers(pageNumber?: number, pageSize?: number): Observable> { - const httpOptions = { - params: new HttpParams() - }; - if (pageNumber) httpOptions.params.set("PageNumber", pageNumber + 1); - if (pageSize) httpOptions.params.set("PageSize", pageSize); - - return this.http.get>(this.apiUrl, httpOptions); + public getTiers(): Observable> { + return this.http.get>(this.apiUrl); } public getTierById(id: string): Observable> { diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/utils/date-filter.ts b/AdminUi/src/AdminUi/ClientApp/src/app/utils/date-filter.ts new file mode 100644 index 0000000000..dc5c27039b --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/utils/date-filter.ts @@ -0,0 +1,4 @@ +export interface DateFilter { + operator?: string; + value?: Date; +} diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/utils/number-filter.ts b/AdminUi/src/AdminUi/ClientApp/src/app/utils/number-filter.ts new file mode 100644 index 0000000000..6c0c917c39 --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/utils/number-filter.ts @@ -0,0 +1,4 @@ +export interface NumberFilter { + operator?: string; + value?: number | null; +} diff --git a/AdminUi/src/AdminUi/ClientApp/src/app/utils/odata-response.ts b/AdminUi/src/AdminUi/ClientApp/src/app/utils/odata-response.ts new file mode 100644 index 0000000000..99f520860c --- /dev/null +++ b/AdminUi/src/AdminUi/ClientApp/src/app/utils/odata-response.ts @@ -0,0 +1,3 @@ +export interface ODataResponse { + value: Type; +} diff --git a/AdminUi/src/AdminUi/ClientApp/src/environments/environment.development.ts b/AdminUi/src/AdminUi/ClientApp/src/environments/environment.development.ts index 1b70c025ea..2a8809bfd4 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/environments/environment.development.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/environments/environment.development.ts @@ -1,4 +1,5 @@ export const environment = { production: false, - apiUrl: "http://localhost:5173/api/v1" + apiUrl: "http://localhost:5173/api/v1", + odataUrl: "http://localhost:5173/odata" }; diff --git a/AdminUi/src/AdminUi/ClientApp/src/environments/environment.ts b/AdminUi/src/AdminUi/ClientApp/src/environments/environment.ts index 47bfc5de0d..24f40014ca 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/environments/environment.ts +++ b/AdminUi/src/AdminUi/ClientApp/src/environments/environment.ts @@ -1,4 +1,5 @@ export const environment = { production: true, - apiUrl: "/api/v1" + apiUrl: "/api/v1", + odataUrl: "/odata" }; diff --git a/AdminUi/src/AdminUi/ClientApp/src/styles.css b/AdminUi/src/AdminUi/ClientApp/src/styles.css index 5394176f94..28f6d6e83b 100644 --- a/AdminUi/src/AdminUi/ClientApp/src/styles.css +++ b/AdminUi/src/AdminUi/ClientApp/src/styles.css @@ -9,3 +9,15 @@ body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } + +/* Chrome, Safari, Edge, Opera */ +input[matinput]::-webkit-outer-spin-button, +input[matinput]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[matinput][type="number"] { + -moz-appearance: textfield; +} diff --git a/AdminUi/src/AdminUi/Controllers/IdentitiesController.cs b/AdminUi/src/AdminUi/Controllers/IdentitiesController.cs index 11d90d0953..515d137b4f 100644 --- a/AdminUi/src/AdminUi/Controllers/IdentitiesController.cs +++ b/AdminUi/src/AdminUi/Controllers/IdentitiesController.cs @@ -1,5 +1,4 @@ -using AdminUi.Infrastructure.DTOs; -using AdminUi.Infrastructure.Persistence.Database; +using AdminUi.Infrastructure.Persistence.Database; using Backbone.Modules.Devices.Application; using Backbone.Modules.Devices.Application.Devices.DTOs; using Backbone.Modules.Devices.Application.Identities.Commands.UpdateIdentity; @@ -7,17 +6,12 @@ using Backbone.Modules.Quotas.Application.Identities.Commands.CreateQuotaForIdentity; using Backbone.Modules.Quotas.Application.Identities.Commands.DeleteQuotaForIdentity; using Backbone.Modules.Quotas.Domain.Aggregates.Identities; -using Enmeshed.BuildingBlocks.API; using Enmeshed.BuildingBlocks.API.Mvc; using Enmeshed.BuildingBlocks.API.Mvc.ControllerAttributes; -using Enmeshed.BuildingBlocks.Application.Abstractions.Exceptions; -using Enmeshed.BuildingBlocks.Application.Extensions; -using Enmeshed.BuildingBlocks.Application.Pagination; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; -using ApplicationException = Enmeshed.BuildingBlocks.Application.Abstractions.Exceptions.ApplicationException; using GetIdentityQueryDevices = Backbone.Modules.Devices.Application.Identities.Queries.GetIdentity.GetIdentityQuery; using GetIdentityQueryQuotas = Backbone.Modules.Quotas.Application.Identities.Queries.GetIdentity.GetIdentityQuery; using GetIdentityResponseDevices = Backbone.Modules.Devices.Application.Identities.Queries.GetIdentity.GetIdentityResponse; @@ -39,19 +33,6 @@ public IdentitiesController( _options = options.Value; } - [HttpGet] - [ProducesResponseType(typeof(PagedHttpResponseEnvelope), StatusCodes.Status200OK)] - public async Task GetIdentities([FromQuery] PaginationFilter paginationFilter, CancellationToken cancellationToken) - { - paginationFilter.PageSize ??= _options.Pagination.DefaultPageSize; - if (paginationFilter.PageSize > _options.Pagination.MaxPageSize) - throw new ApplicationException( - GenericApplicationErrors.Validation.InvalidPageSize(_options.Pagination.MaxPageSize)); - - var identityOverviews = await _adminUiDbContext.IdentityOverviews.OrderAndPaginate(d => d.CreatedAt, paginationFilter, cancellationToken); - return Paged(new PagedResponse(identityOverviews.ItemsOnPage, paginationFilter, identityOverviews.TotalNumberOfItems)); - } - [HttpPost("{identityAddress}/Quotas")] [ProducesResponseType(typeof(IndividualQuotaDTO), StatusCodes.Status201Created)] [ProducesError(StatusCodes.Status404NotFound)] diff --git a/AdminUi/src/AdminUi/Controllers/OData/IdentitiesController.cs b/AdminUi/src/AdminUi/Controllers/OData/IdentitiesController.cs new file mode 100644 index 0000000000..3c744234e0 --- /dev/null +++ b/AdminUi/src/AdminUi/Controllers/OData/IdentitiesController.cs @@ -0,0 +1,22 @@ +using AdminUi.Infrastructure.DTOs; +using AdminUi.Infrastructure.Persistence.Database; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; + +namespace AdminUi.Controllers.OData; + +public class IdentitiesController : ODataController +{ + private readonly AdminUiDbContext _adminUiDbContext; + + public IdentitiesController(AdminUiDbContext adminUiDbContext) + { + _adminUiDbContext = adminUiDbContext; + } + + [EnableQuery] + public IQueryable Get() + { + return _adminUiDbContext.IdentityOverviews; + } +} diff --git a/AdminUi/src/AdminUi/Controllers/TiersController.cs b/AdminUi/src/AdminUi/Controllers/TiersController.cs index 9530a2c6df..e8f80035f4 100644 --- a/AdminUi/src/AdminUi/Controllers/TiersController.cs +++ b/AdminUi/src/AdminUi/Controllers/TiersController.cs @@ -11,14 +11,11 @@ using Enmeshed.BuildingBlocks.API; using Enmeshed.BuildingBlocks.API.Mvc; using Enmeshed.BuildingBlocks.API.Mvc.ControllerAttributes; -using Enmeshed.BuildingBlocks.Application.Abstractions.Exceptions; -using Enmeshed.BuildingBlocks.Application.Extensions; -using Enmeshed.BuildingBlocks.Application.Pagination; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; -using ApplicationException = Enmeshed.BuildingBlocks.Application.Abstractions.Exceptions.ApplicationException; namespace AdminUi.Controllers; @@ -36,16 +33,11 @@ public TiersController(IMediator mediator, IOptions options, } [HttpGet] - [ProducesResponseType(typeof(PagedHttpResponseEnvelope), StatusCodes.Status200OK)] - public async Task GetTiersAsync([FromQuery] PaginationFilter paginationFilter, CancellationToken cancellationToken) + [ProducesResponseType(typeof(HttpResponseEnvelopeResult>), StatusCodes.Status200OK)] + public async Task GetTiers(CancellationToken cancellationToken) { - paginationFilter.PageSize ??= _options.Pagination.DefaultPageSize; - if (paginationFilter.PageSize > _options.Pagination.MaxPageSize) - throw new ApplicationException( - GenericApplicationErrors.Validation.InvalidPageSize(_options.Pagination.MaxPageSize)); - - var tierOverviews = await _adminUiDbContext.TierOverviews.OrderAndPaginate(d => d.Name, paginationFilter, cancellationToken); - return Paged(new PagedResponse(tierOverviews.ItemsOnPage, paginationFilter, tierOverviews.TotalNumberOfItems)); + var tiers = await _adminUiDbContext.TierOverviews.ToListAsync(cancellationToken); + return Ok(tiers); } [HttpGet("{tierId}")] diff --git a/AdminUi/src/AdminUi/Extensions/IServiceCollectionExtensions.cs b/AdminUi/src/AdminUi/Extensions/IServiceCollectionExtensions.cs index c712fc3646..aa3b6a0215 100644 --- a/AdminUi/src/AdminUi/Extensions/IServiceCollectionExtensions.cs +++ b/AdminUi/src/AdminUi/Extensions/IServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ using System.Text.Json.Serialization; using AdminUi.Authentication; using AdminUi.Configuration; +using AdminUi.Infrastructure.DTOs; using Backbone.Modules.Devices.Application.Devices.Commands.RegisterDevice; using Backbone.Modules.Devices.Application.Devices.DTOs; using Enmeshed.BuildingBlocks.API; @@ -9,6 +10,8 @@ using FluentValidation; using FluentValidation.AspNetCore; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData; +using Microsoft.OData.ModelBuilder; namespace AdminUi.Extensions; @@ -165,4 +168,19 @@ public static IServiceCollection AddCustomSwaggerWithUi(this IServiceCollection return services; } + + public static IServiceCollection AddOData(this IServiceCollection services) + { + var builder = new ODataConventionModelBuilder() + .EnableLowerCamelCase(); + + builder.EntitySet("Identities") + .EntityType.HasKey(identity => identity.Address); + + + services.AddControllers().AddOData(opt => opt.Count().Filter().Expand().Select().OrderBy().SetMaxTop(100) + .AddRouteComponents("odata", builder.GetEdmModel())); + + return services; + } } diff --git a/AdminUi/src/AdminUi/Program.cs b/AdminUi/src/AdminUi/Program.cs index 47b28b7b16..15997bd451 100644 --- a/AdminUi/src/AdminUi/Program.cs +++ b/AdminUi/src/AdminUi/Program.cs @@ -14,6 +14,7 @@ using Enmeshed.BuildingBlocks.Infrastructure.Persistence.Database; using Enmeshed.Tooling.Extensions; using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.OData; using Microsoft.Extensions.Options; using Serilog; using Serilog.Exceptions; @@ -100,6 +101,7 @@ static void ConfigureServices(IServiceCollection services, IConfiguration config #pragma warning restore ASP0000 services.AddCustomAspNetCore(parsedConfiguration) + .AddOData() .AddCustomFluentValidation() .AddCustomIdentity(environment) .AddDatabase(parsedConfiguration.Infrastructure.SqlDatabase) diff --git a/AdminUi/test/AdminUi.Tests.Integration/API/BaseApi.cs b/AdminUi/test/AdminUi.Tests.Integration/API/BaseApi.cs index b27002e833..f56cef8590 100644 --- a/AdminUi/test/AdminUi.Tests.Integration/API/BaseApi.cs +++ b/AdminUi/test/AdminUi.Tests.Integration/API/BaseApi.cs @@ -12,6 +12,7 @@ namespace AdminUi.Tests.Integration.API; public class BaseApi { protected const string ROUTE_PREFIX = "/api/v1"; + protected const string ODATA_ROUTE_PREFIX = "/odata"; private readonly HttpClient _httpClient; private const string XSRF_TOKEN_HEADER_NAME = "X-XSRF-TOKEN"; private const string XSRF_TOKEN_COOKIE_NAME = "X-XSRF-COOKIE"; @@ -57,6 +58,11 @@ private async Task LoadXsrfTokensAsync() } } + protected async Task> GetOData(string endpoint, RequestConfiguration requestConfiguration) + { + return await ExecuteODataRequest(HttpMethod.Get, endpoint, requestConfiguration); + } + protected async Task> Get(string endpoint, RequestConfiguration requestConfiguration) { return await ExecuteRequest(HttpMethod.Get, endpoint, requestConfiguration); @@ -105,6 +111,27 @@ private async Task ExecuteRequest(HttpMethod method, string endpoi return response; } + private async Task> ExecuteODataRequest(HttpMethod method, string endpoint, RequestConfiguration requestConfiguration) + { + var request = new HttpRequestMessage(method, ODATA_ROUTE_PREFIX + endpoint); + + var httpResponse = await _httpClient.SendAsync(request); + var responseRawContent = await httpResponse.Content.ReadAsStringAsync(); + + var responseData = JsonConvert.DeserializeObject>(responseRawContent); + + var response = new ODataResponse + { + IsSuccessStatusCode = httpResponse.IsSuccessStatusCode, + StatusCode = httpResponse.StatusCode, + Content = responseData!, + ContentType = httpResponse.Content.Headers.ContentType?.MediaType, + RawContent = responseRawContent + }; + + return response; + } + private async Task> ExecuteRequest(HttpMethod method, string endpoint, RequestConfiguration requestConfiguration) { var request = new HttpRequestMessage(method, ROUTE_PREFIX + endpoint); @@ -117,6 +144,7 @@ private async Task> ExecuteRequest(HttpMethod method, string var httpResponse = await _httpClient.SendAsync(request); var responseRawContent = await httpResponse.Content.ReadAsStringAsync(); + var responseData = JsonConvert.DeserializeObject>(responseRawContent); var response = new HttpResponse diff --git a/AdminUi/test/AdminUi.Tests.Integration/API/IdentitiesApi.cs b/AdminUi/test/AdminUi.Tests.Integration/API/IdentitiesApi.cs index b84aad0f1f..2d9a3e3e17 100644 --- a/AdminUi/test/AdminUi.Tests.Integration/API/IdentitiesApi.cs +++ b/AdminUi/test/AdminUi.Tests.Integration/API/IdentitiesApi.cs @@ -23,8 +23,8 @@ public async Task DeleteIndividualQuota(string identityAddress, st return await Delete($"/Identities/{identityAddress}/Quotas/{individualQuotaId}", requestConfiguration); } - public async Task>?> GetIdentityOverviews(RequestConfiguration requestConfiguration) + public async Task>?> GetIdentityOverviews(RequestConfiguration requestConfiguration) { - return await Get>("/Identities", requestConfiguration); + return await GetOData>("/Identities", requestConfiguration); } } diff --git a/AdminUi/test/AdminUi.Tests.Integration/Extensions/ODataResponseExtensions.cs b/AdminUi/test/AdminUi.Tests.Integration/Extensions/ODataResponseExtensions.cs new file mode 100644 index 0000000000..bd0cfa7b8e --- /dev/null +++ b/AdminUi/test/AdminUi.Tests.Integration/Extensions/ODataResponseExtensions.cs @@ -0,0 +1,27 @@ +using AdminUi.Tests.Integration.Models; +using AdminUi.Tests.Integration.Support; + +namespace AdminUi.Tests.Integration.Extensions; +public static class ODataResponseExtensions +{ + public static void AssertHasValue(this ODataResponse response) + { + response.Should().NotBeNull(); + } + + public static void AssertStatusCodeIsSuccess(this ODataResponse response) + { + response.IsSuccessStatusCode.Should().BeTrue(); + } + + public static void AssertContentTypeIs(this ODataResponse response, string contentType) + { + response.ContentType.Should().Be(contentType); + } + + public static void AssertContentCompliesWithSchema(this ODataResponse response) + { + JsonValidators.ValidateJsonSchema>(response.RawContent!, out var errors) + .Should().BeTrue($"Response content does not comply with the {typeof(T).FullName} schema: {string.Join(", ", errors)}"); + } +} diff --git a/AdminUi/test/AdminUi.Tests.Integration/Models/ODataResponse.cs b/AdminUi/test/AdminUi.Tests.Integration/Models/ODataResponse.cs new file mode 100644 index 0000000000..8d821132a4 --- /dev/null +++ b/AdminUi/test/AdminUi.Tests.Integration/Models/ODataResponse.cs @@ -0,0 +1,12 @@ +using System.Net; + +namespace AdminUi.Tests.Integration.Models; + +public class ODataResponse +{ + public ODataResponseContent Content { get; set; } + public HttpStatusCode StatusCode { get; set; } + public bool IsSuccessStatusCode { get; set; } + public string? ContentType { get; set; } + public string? RawContent { get; set; } +} diff --git a/AdminUi/test/AdminUi.Tests.Integration/Models/ODataResponseContent.cs b/AdminUi/test/AdminUi.Tests.Integration/Models/ODataResponseContent.cs new file mode 100644 index 0000000000..2fc5224ab1 --- /dev/null +++ b/AdminUi/test/AdminUi.Tests.Integration/Models/ODataResponseContent.cs @@ -0,0 +1,6 @@ +namespace AdminUi.Tests.Integration.Models; + +public class ODataResponseContent +{ + public T? Value { get; set; } +} diff --git a/AdminUi/test/AdminUi.Tests.Integration/StepDefinitions/IdentitiesApiStepDefinitions.cs b/AdminUi/test/AdminUi.Tests.Integration/StepDefinitions/IdentitiesApiStepDefinitions.cs index c2b51a9035..b28411102d 100644 --- a/AdminUi/test/AdminUi.Tests.Integration/StepDefinitions/IdentitiesApiStepDefinitions.cs +++ b/AdminUi/test/AdminUi.Tests.Integration/StepDefinitions/IdentitiesApiStepDefinitions.cs @@ -10,7 +10,7 @@ namespace AdminUi.Tests.Integration.StepDefinitions; public class IdentitiesApiStepDefinitions : BaseStepDefinitions { private readonly IdentitiesApi _identitiesApi; - private HttpResponse>? _identityOverviewsResponse; + private ODataResponse>? _identityOverviewsResponse; private HttpResponse? _identityResponse; private string _existingIdentity; @@ -53,8 +53,8 @@ public async Task WhenAGETRequestIsSentToTheIdentitiesAddressEndpointForAnInexis [Then(@"the response contains a list of Identities")] public void ThenTheResponseContainsAListOfIdentities() { - _identityOverviewsResponse!.Content.Result.Should().NotBeNull(); - _identityOverviewsResponse!.Content.Result.Should().NotBeNullOrEmpty(); + _identityOverviewsResponse!.Content.Value.Should().NotBeNull(); + _identityOverviewsResponse!.Content.Value.Should().NotBeNullOrEmpty(); _identityOverviewsResponse!.AssertContentTypeIs("application/json"); _identityOverviewsResponse!.AssertContentCompliesWithSchema(); }