From 3eab61e4de946c54b5c14a05538fffdd19c424e2 Mon Sep 17 00:00:00 2001 From: Mihail Date: Tue, 31 Mar 2026 16:19:03 +0200 Subject: [PATCH 1/2] feat: display "Users and Permissions" plugin Roles and their permissions, add user creation/invitation for the "Users and Permissions" plugin, refactor the settings sidebar TODO: - there is a lot of duplicate code between the regular user and users and permissions plugin for the new user creation/invitation flow, try to extract the common logic. - Add password reset option - Add deletion button for the users and permissions plugin users --- CCUI.DAPPI/angular.json | 3 + CCUI.DAPPI/proxy.conf.json | 8 + .../complete-invitation.component.ts | 12 +- .../invite-user-dialog.component.ts | 49 +++- .../users-and-permissions-plugin.service.ts | 44 ++- .../src/app/settings/settings.component.html | 16 +- .../src/app/settings/settings.component.ts | 19 +- ...nd-permissions-plugin-roles.component.html | 143 +++++++++ ...nd-permissions-plugin-roles.component.scss | 267 +++++++++++++++++ ...-and-permissions-plugin-roles.component.ts | 174 +++++++++++ ...nd-permissions-plugin-users.component.html | 80 +++++ ...nd-permissions-plugin-users.component.scss | 150 ++++++++++ ...-and-permissions-plugin-users.component.ts | 115 ++++++++ .../Exceptions/UserAlreadyExistsException.cs | 2 +- Dappi.Core/Interfaces/IEmailSerevice.cs | 22 ++ Dappi.Core/Invitation/InvitationEmail.html | 5 + Dappi.Core/Invitation/InvitationEmail.txt | 9 + .../Api/Dtos.cs | 18 ++ .../Api/UsersAndPermissionsController.cs | 275 +++++++++++++++++- .../Extensions.cs | 14 +- .../Interfaces/IInvitationService.cs | 13 + .../Models/AuthDtos.cs | 31 ++ .../Services/Identity/InvitationService.cs | 233 +++++++++++++++ .../Services/MailServices/AwsSesService.cs | 0 .../Controllers/PluginsController.cs | 3 +- .../Controllers/UsersController.cs | 14 +- Dappi.HeadlessCms/Dappi.HeadlessCms.csproj | 12 +- Dappi.HeadlessCms/Interfaces/IEmailService.cs | 25 -- .../Middleware/ExceptionHandlingMiddleware.cs | 1 + Dappi.HeadlessCms/ServiceExtensions.cs | 7 +- .../Services/Identity/InvitationService.cs | 4 +- .../MyCompany.MyProject.WebApi.csproj | 5 +- .../MyCompany.MyProject.WebApi/Program.cs | 18 +- ...256_InitialUsersAndPermissions.Designer.cs | 237 +++++++++++++++ ...260318142256_InitialUsersAndPermissions.cs | 182 ++++++++++++ ...ersAndPermissionsDbContextModelSnapshot.cs | 234 +++++++++++++++ templates/MyCompany.MyProject.sln | 14 + 37 files changed, 2388 insertions(+), 70 deletions(-) create mode 100644 CCUI.DAPPI/proxy.conf.json create mode 100644 CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.html create mode 100644 CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.scss create mode 100644 CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.ts create mode 100644 CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.html create mode 100644 CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.scss create mode 100644 CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.ts rename {Dappi.HeadlessCms => Dappi.Core}/Exceptions/UserAlreadyExistsException.cs (77%) create mode 100644 Dappi.Core/Interfaces/IEmailSerevice.cs create mode 100644 Dappi.Core/Invitation/InvitationEmail.html create mode 100644 Dappi.Core/Invitation/InvitationEmail.txt create mode 100644 Dappi.HeadlessCms.UsersAndPermissions/Interfaces/IInvitationService.cs create mode 100644 Dappi.HeadlessCms.UsersAndPermissions/Models/AuthDtos.cs create mode 100644 Dappi.HeadlessCms.UsersAndPermissions/Services/Identity/InvitationService.cs create mode 100644 Dappi.HeadlessCms.UsersAndPermissions/Services/MailServices/AwsSesService.cs delete mode 100644 Dappi.HeadlessCms/Interfaces/IEmailService.cs create mode 100644 templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/20260318142256_InitialUsersAndPermissions.Designer.cs create mode 100644 templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/20260318142256_InitialUsersAndPermissions.cs create mode 100644 templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/AppUsersAndPermissionsDbContextModelSnapshot.cs diff --git a/CCUI.DAPPI/angular.json b/CCUI.DAPPI/angular.json index 4f93bf77..82c9173f 100644 --- a/CCUI.DAPPI/angular.json +++ b/CCUI.DAPPI/angular.json @@ -86,6 +86,9 @@ }, "serve": { "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "proxyConfig": "proxy.conf.json" + }, "configurations": { "production": { "buildTarget": "CCUI.DAPPI:build:production" diff --git a/CCUI.DAPPI/proxy.conf.json b/CCUI.DAPPI/proxy.conf.json new file mode 100644 index 00000000..a11036d8 --- /dev/null +++ b/CCUI.DAPPI/proxy.conf.json @@ -0,0 +1,8 @@ +{ + "/api": { + "target": "http://localhost:5149", + "secure": false, + "changeOrigin": true, + "logLevel": "debug" + } +} diff --git a/CCUI.DAPPI/src/app/complete-invitation/complete-invitation.component.ts b/CCUI.DAPPI/src/app/complete-invitation/complete-invitation.component.ts index f3559fee..60a9d55f 100644 --- a/CCUI.DAPPI/src/app/complete-invitation/complete-invitation.component.ts +++ b/CCUI.DAPPI/src/app/complete-invitation/complete-invitation.component.ts @@ -3,6 +3,7 @@ import { Component, OnInit } from '@angular/core'; import { AbstractControl, FormBuilder, FormGroup, ReactiveFormsModule, ValidatorFn, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { UsersManagementService } from '../services/auth/users-management.service'; +import { UsersAndPermissionsPluginService } from '../services/auth/users-and-permissions-plugin.service'; export function passwordMatchValidator(): ValidatorFn { return (control: AbstractControl): { [key: string]: any } | null => { @@ -27,6 +28,7 @@ export function passwordMatchValidator(): ValidatorFn { export class CompleteInvitationComponent implements OnInit { form: FormGroup; token = ''; + flow = ''; isSubmitting = false; errorMessage = ''; @@ -34,7 +36,8 @@ export class CompleteInvitationComponent implements OnInit { private fb: FormBuilder, private route: ActivatedRoute, private router: Router, - private usersManagementService: UsersManagementService + private usersManagementService: UsersManagementService, + private usersAndPermissionsPluginService: UsersAndPermissionsPluginService ) { this.form = this.fb.group( { @@ -48,6 +51,7 @@ export class CompleteInvitationComponent implements OnInit { ngOnInit(): void { this.token = this.route.snapshot.queryParamMap.get('token') ?? ''; + this.flow = (this.route.snapshot.queryParamMap.get('flow') ?? '').toLowerCase(); if (!this.token) { this.errorMessage = 'Invitation token is missing.'; @@ -71,7 +75,11 @@ export class CompleteInvitationComponent implements OnInit { const oldPassword = this.form.get('oldPassword')?.value; const newPassword = this.form.get('newPassword')?.value; - this.usersManagementService.completeInvitation({ token: this.token, oldPassword, newPassword }).subscribe({ + const completeInvitation$ = this.flow === 'usersandpermissions' + ? this.usersAndPermissionsPluginService.completeInvitation({ token: this.token, oldPassword, newPassword }) + : this.usersManagementService.completeInvitation({ token: this.token, oldPassword, newPassword }); + + completeInvitation$.subscribe({ next: () => { this.isSubmitting = false; this.router.navigate(['/auth'], { queryParams: { invitationCompleted: 'true' } }); diff --git a/CCUI.DAPPI/src/app/invite-user-dialog/invite-user-dialog.component.ts b/CCUI.DAPPI/src/app/invite-user-dialog/invite-user-dialog.component.ts index bbf7d5b0..1a16b14f 100644 --- a/CCUI.DAPPI/src/app/invite-user-dialog/invite-user-dialog.component.ts +++ b/CCUI.DAPPI/src/app/invite-user-dialog/invite-user-dialog.component.ts @@ -8,6 +8,7 @@ import { MatSelectModule } from '@angular/material/select'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { RolesManagementService, RoleItem } from '../services/auth/roles-management.service'; +import { UsersAndPermissionsPluginService } from '../services/auth/users-and-permissions-plugin.service'; export interface InviteUserData { username: string; @@ -18,6 +19,7 @@ export interface InviteUserData { export interface InviteUserDialogConfig { isEmailServiceAvailable: boolean; + usePluginRoles?: boolean; } @Component({ @@ -47,20 +49,17 @@ export class InviteUserDialogComponent implements OnInit { constructor( private dialogRef: MatDialogRef, private rolesManagementService: RolesManagementService, + private usersAndPermissionsPluginService: UsersAndPermissionsPluginService, @Inject(MAT_DIALOG_DATA) public data: InviteUserDialogConfig, ) {} ngOnInit(): void { - this.rolesLoading = true; - this.rolesManagementService.getRoles().subscribe({ - next: (res) => { - this.availableRoles = res.Data; - this.rolesLoading = false; - }, - error: () => { - this.rolesLoading = false; - }, - }); + if (this.data.usePluginRoles) { + this.loadPluginRoles(); + return; + } + + this.loadRegularRoles(); } get isValid(): boolean { @@ -84,4 +83,34 @@ export class InviteUserDialogComponent implements OnInit { onCancel(): void { this.dialogRef.close(null); } + + private loadRegularRoles(): void { + this.rolesLoading = true; + this.rolesManagementService.getRoles().subscribe({ + next: (res) => { + this.availableRoles = res.Data; + this.rolesLoading = false; + }, + error: () => { + this.rolesLoading = false; + }, + }); + } + + private loadPluginRoles(): void { + this.rolesLoading = true; + this.usersAndPermissionsPluginService.getAllRoles().subscribe({ + next: (roles) => { + this.availableRoles = roles.map((role) => ({ + Id: role.id, + Name: role.name, + UserCount: 0, + })); + this.rolesLoading = false; + }, + error: () => { + this.rolesLoading = false; + }, + }); + } } diff --git a/CCUI.DAPPI/src/app/services/auth/users-and-permissions-plugin.service.ts b/CCUI.DAPPI/src/app/services/auth/users-and-permissions-plugin.service.ts index bd9ce9c3..d128c961 100644 --- a/CCUI.DAPPI/src/app/services/auth/users-and-permissions-plugin.service.ts +++ b/CCUI.DAPPI/src/app/services/auth/users-and-permissions-plugin.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { map, Observable } from 'rxjs'; import { BASE_API_URL } from '../../../Constants'; +import { CompleteInvitationRequest, InviteUserRequest } from './users-management.service'; export interface UsersAndPermissionsRoleItem { id: string; @@ -9,6 +10,16 @@ export interface UsersAndPermissionsRoleItem { isDefaultForAuthenticatedUser: boolean; } +export interface UsersAndPermissionsUserItem { + id: number; + userName: string; + email: string; + emailConfirmed: boolean; + acceptedInvitation?: boolean; + roleId: number; + roleName: string; +} + export interface UsersAndPermissionsPermissionItem { permissionName: string; description: string; @@ -24,7 +35,7 @@ export type UsersAndPermissionsRolePermissionsResponse = Record< export class UsersAndPermissionsPluginService { private readonly endpoint = 'usersandpermissions'; - constructor(private http: HttpClient) {} + constructor(private http: HttpClient) { } getAllRoles(): Observable { return this.http.get(`${BASE_API_URL}${this.endpoint}/roles`).pipe( @@ -32,6 +43,12 @@ export class UsersAndPermissionsPluginService { ); } + getAllUsers(): Observable { + return this.http.get(`${BASE_API_URL}${this.endpoint}/users`).pipe( + map((users) => (users ?? []).map((user) => this.normalizeUser(user))) + ); + } + getRolePermissions(roleName: string): Observable { const params = new HttpParams().set('roleName', roleName); @@ -47,6 +64,19 @@ export class UsersAndPermissionsPluginService { ); } + inviteUser(data: InviteUserRequest): Observable { + return this.http + .post(`${BASE_API_URL}${this.endpoint}`, data) + .pipe(map((user) => this.normalizeUser(user))); + } + + completeInvitation(data: CompleteInvitationRequest): Observable<{ message: string }> { + return this.http.post<{ message: string }>( + `${BASE_API_URL}${this.endpoint}/complete-invitation`, + data + ); + } + private normalizeRole(role: any): UsersAndPermissionsRoleItem { return { id: role?.id ?? role?.Id ?? '', @@ -63,4 +93,16 @@ export class UsersAndPermissionsPluginService { selected: permission?.selected ?? permission?.Selected ?? false, }; } + + private normalizeUser(user: any): UsersAndPermissionsUserItem { + return { + id: user?.id ?? user?.Id ?? 0, + userName: user?.userName ?? user?.UserName ?? '', + email: user?.email ?? user?.Email ?? '', + emailConfirmed: user?.emailConfirmed ?? user?.EmailConfirmed ?? false, + acceptedInvitation: user?.acceptedInvitation ?? user?.AcceptedInvitation ?? false, + roleId: user?.roleId ?? user?.RoleId ?? 0, + roleName: user?.roleName ?? user?.RoleName ?? '', + }; + } } diff --git a/CCUI.DAPPI/src/app/settings/settings.component.html b/CCUI.DAPPI/src/app/settings/settings.component.html index f10a2d67..aefd0380 100644 --- a/CCUI.DAPPI/src/app/settings/settings.component.html +++ b/CCUI.DAPPI/src/app/settings/settings.component.html @@ -24,10 +24,20 @@ class="settings-sidebar__item" [class.settings-sidebar__item--active]="activeTab === 'usersAndPermissionsPlugin'" (click)="selectTab('usersAndPermissionsPlugin')" - aria-label="Users and permissions plugin" + aria-label="Roles and permissions" > shield - Users & Permissions plugin + Roles & Permissions + + + } @@ -59,6 +69,8 @@
@if (activeTab === 'storage') { + } @else if (activeTab === 'usersAndPermissionsPluginUsers') { + } @else if (activeTab === 'usersAndPermissionsPlugin') {
diff --git a/CCUI.DAPPI/src/app/settings/settings.component.ts b/CCUI.DAPPI/src/app/settings/settings.component.ts index 6af46762..eb79715b 100644 --- a/CCUI.DAPPI/src/app/settings/settings.component.ts +++ b/CCUI.DAPPI/src/app/settings/settings.component.ts @@ -1,4 +1,5 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; @@ -7,6 +8,7 @@ import { Subscription } from 'rxjs'; import { DataStorageComponent } from './data-storage/data-storage.component'; import { UsersComponent } from './users/users.component'; import { RolesComponent } from './roles/roles.component'; +import { UsersAndPermissionsUsersComponent } from './users-and-permissions-plugin-users/users-and-permissions-plugin-users.component'; import { UsersManagementService } from '../services/auth/users-management.service'; import { UsersAndPermissionsPluginService, @@ -26,12 +28,13 @@ interface ControllerPermissionGroup { allowedCount: number; } -type SettingsTab = 'storage' | 'users' | 'roles' | 'usersAndPermissionsPlugin'; +type SettingsTab = 'storage' | 'users' | 'roles' | 'usersAndPermissionsPlugin' | 'usersAndPermissionsPluginUsers'; @Component({ selector: 'app-settings', standalone: true, imports: [ + CommonModule, MatButtonToggleModule, MatIconModule, MatProgressSpinnerModule, @@ -45,6 +48,7 @@ type SettingsTab = 'storage' | 'users' | 'roles' | 'usersAndPermissionsPlugin'; }) export class SettingsComponent implements OnInit, OnDestroy { private subscription = new Subscription(); + readonly usersAndPermissionsUsersComponent = UsersAndPermissionsUsersComponent; activeTab: SettingsTab = 'storage'; usersAndPermissionsEnabled = false; @@ -75,7 +79,10 @@ export class SettingsComponent implements OnInit, OnDestroy { } selectTab(tab: SettingsTab): void { - if (tab === 'usersAndPermissionsPlugin' && !this.usersAndPermissionsEnabled) { + if ( + (tab === 'usersAndPermissionsPlugin' || tab === 'usersAndPermissionsPluginUsers') + && !this.usersAndPermissionsEnabled + ) { return; } @@ -92,7 +99,13 @@ export class SettingsComponent implements OnInit, OnDestroy { next: (response) => { this.usersAndPermissionsEnabled = !!response.services?.['usersAndPermissions']; - if (!this.usersAndPermissionsEnabled && this.activeTab === 'usersAndPermissionsPlugin') { + if ( + !this.usersAndPermissionsEnabled + && ( + this.activeTab === 'usersAndPermissionsPlugin' + || this.activeTab === 'usersAndPermissionsPluginUsers' + ) + ) { this.activeTab = 'storage'; } }, diff --git a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.html b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.html new file mode 100644 index 00000000..46945cca --- /dev/null +++ b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.html @@ -0,0 +1,143 @@ +
+
+

Users & Permissions plugin

+

View the roles and their permissions

+
+
+ +@if (usersAndPermissionsRolesError) { +
{{ usersAndPermissionsRolesError }}
+} + +@if (usersAndPermissionsRolesLoading) { +
+ +

Loading roles...

+
+} @else if (!usersAndPermissionsRoles.length) { +
+ No roles +

There are no roles yet

+

No roles were returned from the Users & Permissions plugin.

+
+} @else { +
+
+ + + + + + + + +
Name{{ role.name }}
+
+ +
+ @if (selectedUsersAndPermissionsRole) { +
+

{{ selectedUsersAndPermissionsRole.name }}

+
+ + @if (usersAndPermissionsRoleDetailsError) { +
{{ usersAndPermissionsRoleDetailsError }}
+ } + + @if (usersAndPermissionsRoleDetailsLoading) { +
+ +

Loading permissions...

+
+ } @else if (!selectedUsersAndPermissionsRolePermissionGroups.length) { +
+

No permissions found

+

This role currently has no configured permissions response.

+
+ } @else { +
+ + + + + + + + + @for (group of selectedUsersAndPermissionsRolePermissionGroups; track group.controller) { + + + + + + @if (isControllerExpanded(group.controller)) { + + + + } + } + +
ControllerSummary
+ + + {{ group.allowedCount }}/{{ group.rows.length }} allowed +
+
+ + + + + + + + + + + + + + + + + + +
Permission{{ row.permissionName }}Description + + {{ row.description }} + + Status + + Allowed + Not Allowed + +
+
+
+
+ } + } +
+
+} diff --git a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.scss b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.scss new file mode 100644 index 00000000..fde14097 --- /dev/null +++ b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.scss @@ -0,0 +1,267 @@ +@use '../../../_variables' as vars; + +:host { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.plugin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + + .plugin-title-block { + h1 { + margin: 0; + font-size: 24px; + } + + p { + margin: 4px 0 0; + font-size: 14px; + color: vars.$text-secondary; + } + } +} + +.plugin-error { + color: vars.$error-light; + margin-bottom: 16px; + font-size: 14px; +} + +.plugin-loading, +.plugin-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + + p { + margin-top: 12px; + color: vars.$text-secondary; + } +} + +.plugin-empty { + img { + margin-bottom: 24px; + max-width: 200px; + } + + h2 { + margin: 0 0 8px; + font-size: 20px; + font-weight: 500; + } + + p { + margin: 0; + font-size: 14px; + } +} + +.plugin-roles-layout { + display: grid; + grid-template-columns: minmax(260px, 320px) 1fr; + gap: 16px; + min-height: 0; + flex: 1; +} + +.plugin-roles-table-container, +.plugin-permissions-table-container { + background-color: vars.$background-paper-elevation-1; + border: vars.$divider; + border-radius: 8px; + overflow: auto; +} + +.plugin-roles-table, +.plugin-permissions-table { + width: 100%; + border-spacing: 0; + border-collapse: collapse; +} + +.plugin-roles-table .mat-mdc-header-row, +.plugin-roles-table .mat-mdc-row, +.plugin-roles-table .mat-mdc-header-cell, +.plugin-roles-table .mat-mdc-cell, +.plugin-permissions-table .mat-mdc-header-row, +.plugin-permissions-table .mat-mdc-row, +.plugin-permissions-table .mat-mdc-header-cell, +.plugin-permissions-table .mat-mdc-cell { + background-color: vars.$background-paper-elevation-1; + color: vars.$text-primary; +} + +.plugin-role-row { + cursor: pointer; + + &:hover { + background-color: rgba(vars.$primary-main, 0.12); + } +} + +.plugin-role-row--active { + background-color: rgba(vars.$primary-main, 0.18) !important; +} + +.plugin-permissions-table { + table-layout: fixed; +} + +.plugin-controllers-table { + border-bottom: vars.$divider; +} + +.plugin-controllers-table th, +.plugin-controllers-table td { + padding: 12px 14px; + border-bottom: vars.$divider; + color: vars.$text-primary; + text-align: left; +} + +.plugin-controllers-table th { + font-size: 14px; + font-weight: 600; +} + +.plugin-controller-details-row > td { + padding: 0; +} + +.plugin-controller-toggle { + border: none; + background: transparent; + color: vars.$text-primary; + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + padding: 0; + font: inherit; +} + +.plugin-controller-toggle__icon { + transition: transform 0.2s ease; +} + +.plugin-controller-toggle__icon--expanded { + transform: rotate(180deg); +} + +.plugin-controller-details { + padding: 8px 0 0; +} + +.plugin-permissions-table .mat-mdc-header-cell, +.plugin-permissions-table .mat-mdc-cell { + white-space: normal; + word-break: break-word; + border-bottom-color: rgba(vars.$text-primary, 0.12) !important; +} + +:host ::ng-deep .plugin-permissions-table .mdc-data-table__header-row, +:host ::ng-deep .plugin-permissions-table .mdc-data-table__row { + border-bottom-color: rgba(vars.$text-primary, 0.12) !important; +} + +.plugin-role-details { + border: vars.$divider; + border-radius: 8px; + background-color: vars.$background-paper-elevation-1; + padding: 16px; + overflow: auto; +} + +.plugin-role-details__header { + margin-bottom: 12px; + + h2 { + margin: 0; + font-size: 20px; + } + + p { + margin: 4px 0 0; + font-size: 13px; + color: vars.$text-secondary; + } +} + +.plugin-loading--details, +.plugin-empty--details { + min-height: 200px; +} + +.plugin-empty--details { + align-items: flex-start; + text-align: left; + + h3 { + margin: 0 0 6px; + font-size: 18px; + } + + p { + margin: 0; + } +} + +.plugin-permission-description { + display: inline-flex; + align-items: center; + font-size: 12px; + font-weight: 600; + color: vars.$text-secondary; + background-color: vars.$primary-focus; + border-radius: 12px; + padding: 4px 10px; +} + +.plugin-status-toggle { + border-radius: 14px; +} + +:host ::ng-deep .plugin-status-toggle.mat-button-toggle-group { + border: 0px solid vars.$default-enabled-border; + background-color: vars.$background-default; + overflow: hidden; +} + +:host ::ng-deep .plugin-status-toggle .mat-button-toggle { + background-color: transparent; + color: vars.$text-secondary; +} + +:host ::ng-deep .plugin-status-toggle .mat-button-toggle + .mat-button-toggle { + border-left: 1px solid rgba(vars.$text-primary, 0.16); +} + +:host ::ng-deep .plugin-status-toggle .mat-button-toggle-checked { + background-color: vars.$background-paper-elevation-8; + color: vars.$text-primary; +} + +:host ::ng-deep .plugin-status-toggle .mat-button-toggle-label-content { + font-size: 12px; + line-height: 24px; + padding: 0 8px; +} + +:host ::ng-deep .plugin-status-toggle .mat-pseudo-checkbox { + color: rgba(vars.$text-primary, 0.98) !important; + opacity: 1 !important; +} + +:host ::ng-deep .plugin-status-toggle .mat-pseudo-checkbox::after { + border-color: rgba(vars.$text-primary, 0.98) !important; +} diff --git a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.ts b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.ts new file mode 100644 index 00000000..de4d6a60 --- /dev/null +++ b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-roles/users-and-permissions-plugin-roles.component.ts @@ -0,0 +1,174 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule } from '@angular/material/table'; +import { Subscription } from 'rxjs'; +import { + UsersAndPermissionsPluginService, + UsersAndPermissionsRoleItem, +} from '../../services/auth/users-and-permissions-plugin.service'; + +interface PermissionTableRow { + controller: string; + permissionName: string; + description: string; + selected: boolean; +} + +interface ControllerPermissionGroup { + controller: string; + rows: PermissionTableRow[]; + allowedCount: number; +} + +@Component({ + selector: 'app-users-and-permissions-plugin', + standalone: true, + imports: [MatButtonToggleModule, MatIconModule, MatProgressSpinnerModule, MatTableModule], + templateUrl: './users-and-permissions-plugin-roles.component.html', + styleUrl: './users-and-permissions-plugin-roles.component.scss', +}) +export class UsersAndPermissionsPluginComponent implements OnInit, OnDestroy { + private subscription = new Subscription(); + + usersAndPermissionsRoles: UsersAndPermissionsRoleItem[] = []; + usersAndPermissionsRoleColumns: string[] = ['name']; + selectedUsersAndPermissionsRole: UsersAndPermissionsRoleItem | null = null; + selectedUsersAndPermissionsRolePermissionGroups: ControllerPermissionGroup[] = []; + usersAndPermissionsControllerColumns: string[] = ['controller', 'summary']; + usersAndPermissionsPermissionColumns: string[] = ['permission', 'description', 'state']; + expandedControllers = new Set(); + usersAndPermissionsRolesLoading = false; + usersAndPermissionsRolesError = ''; + usersAndPermissionsRoleDetailsLoading = false; + usersAndPermissionsRoleDetailsError = ''; + + constructor(private usersAndPermissionsPluginService: UsersAndPermissionsPluginService) {} + + ngOnInit(): void { + this.loadUsersAndPermissionsRoles(); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + selectUsersAndPermissionsRole(role: UsersAndPermissionsRoleItem): void { + this.selectedUsersAndPermissionsRole = role; + this.expandedControllers.clear(); + this.loadUsersAndPermissionsRoleDetails(role.name); + } + + toggleController(controller: string): void { + if (this.expandedControllers.has(controller)) { + this.expandedControllers.delete(controller); + return; + } + + this.expandedControllers.add(controller); + } + + isControllerExpanded(controller: string): boolean { + return this.expandedControllers.has(controller); + } + + getEndpointMethodColor(description: string): string { + const method = (description || '').split('/')[0].trim().toUpperCase(); + + switch (method) { + case 'GET': + return '#164891'; + case 'PUT': + return '#ae6303'; + case 'PATCH': + return '#2f9480'; + case 'POST': + return '#2e9471'; + case 'DELETE': + return '#a40504'; + default: + return ''; + } + } + + private loadUsersAndPermissionsRoles(): void { + this.usersAndPermissionsRolesLoading = true; + this.usersAndPermissionsRolesError = ''; + + this.subscription.add( + this.usersAndPermissionsPluginService.getAllRoles().subscribe({ + next: (roles) => { + this.usersAndPermissionsRoles = roles; + this.usersAndPermissionsRolesLoading = false; + + if (!this.selectedUsersAndPermissionsRole && roles.length) { + this.selectUsersAndPermissionsRole(roles[0]); + } + }, + error: (error) => { + this.usersAndPermissionsRoles = []; + this.selectedUsersAndPermissionsRole = null; + this.selectedUsersAndPermissionsRolePermissionGroups = []; + this.expandedControllers.clear(); + this.usersAndPermissionsRolesError = this.getApiErrorMessage(error); + this.usersAndPermissionsRolesLoading = false; + }, + }) + ); + } + + private loadUsersAndPermissionsRoleDetails(roleName: string): void { + this.usersAndPermissionsRoleDetailsLoading = true; + this.usersAndPermissionsRoleDetailsError = ''; + + this.subscription.add( + this.usersAndPermissionsPluginService.getRolePermissions(roleName).subscribe({ + next: (permissions) => { + this.selectedUsersAndPermissionsRolePermissionGroups = Object.entries(permissions) + .map(([controller, permissionItems]) => { + const rows = permissionItems + .map((permission) => ({ + controller, + permissionName: permission.permissionName, + description: permission.description || '-', + selected: permission.selected, + })) + .sort((a, b) => a.permissionName.localeCompare(b.permissionName)); + + return { + controller, + rows, + allowedCount: rows.filter((row) => row.selected).length, + }; + }) + .sort((a, b) => a.controller.localeCompare(b.controller)); + + if (this.selectedUsersAndPermissionsRolePermissionGroups.length) { + this.expandedControllers.add( + this.selectedUsersAndPermissionsRolePermissionGroups[0].controller + ); + } + this.usersAndPermissionsRoleDetailsLoading = false; + }, + error: (error) => { + this.selectedUsersAndPermissionsRolePermissionGroups = []; + this.expandedControllers.clear(); + this.usersAndPermissionsRoleDetailsError = this.getApiErrorMessage(error); + this.usersAndPermissionsRoleDetailsLoading = false; + }, + }) + ); + } + + private getApiErrorMessage(error: any): string { + return ( + error?.error?.message || + error?.error?.title || + error?.error?.error || + (error?.status + ? `Failed to load users and permissions data (${error.status}${error?.statusText ? ` ${error.statusText}` : ''})${error?.url ? `: ${error.url}` : '.'}` + : 'Failed to load users and permissions data.') + ); + } +} diff --git a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.html b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.html new file mode 100644 index 00000000..ef097e04 --- /dev/null +++ b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.html @@ -0,0 +1,80 @@ +
+
+

Users & Permissions plugin - Users

+

Manage users and their assigned roles

+
+ +
+ +@if (usersAndPermissionsUsersError) { +
{{ usersAndPermissionsUsersError }}
+} + +@if (inviteError) { +
{{ inviteError }}
+} + +@if (usersAndPermissionsUsersLoading) { +
+ +

Loading users...

+
+} @else if (!usersAndPermissionsUsers.length) { +
+ No users +

There are no users yet

+

Users are managed through the Users & Permissions plugin

+
+} @else { +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Username{{ user.userName }}Email{{ user.email }}Role{{ user.roleName || '-' }}Email confirmed + + @if (user.emailConfirmed) { + check_circle + } @else { + cancel + } + + Accepted Invitation + + @if (user.acceptedInvitation) { + check_circle + } @else { + cancel + } + +
+
+
+} diff --git a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.scss b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.scss new file mode 100644 index 00000000..8beca37e --- /dev/null +++ b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.scss @@ -0,0 +1,150 @@ +@use '../../../variables' as vars; + +:host { + display: flex; + flex-direction: column; + flex: 1; + --mat-table-background-color: #{vars.$background-paper-elevation-1}; + --mat-table-header-headline-color: #{vars.$text-primary}; + --mat-table-row-item-label-text-color: #{vars.$text-primary}; + --mat-table-row-item-outline-color: rgba(255, 255, 255, 0.12); +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + + .title-block { + h1 { + margin: 0; + font-size: 24px; + } + + p { + margin: 4px 0 0; + font-size: 14px; + color: vars.$text-secondary; + } + } +} + +.create-button { + border: none; + border-radius: 6px; + background: vars.$primary-main; + color: vars.$primary-contrast-text; + height: 40px; + padding: 0 16px; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.error-message { + color: vars.$error-light; + margin-bottom: 16px; + font-size: 14px; +} + +.content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 32px 0; + + img { + margin-bottom: 24px; + max-width: 200px; + } + + h2 { + margin: 0 0 8px; + font-size: 20px; + font-weight: 500; + } + + p { + margin: 0 0 24px; + font-size: 14px; + color: vars.$text-secondary; + } +} + +.content-list { + flex: 1; + display: flex; + flex-direction: column; + margin-bottom: 24px; + overflow: auto; + width: 100%; + position: relative; + contain: size; +} + +.plugin-roles-table-container { + width: 100%; + background-color: vars.$background-paper-elevation-1; + border: vars.$divider; + border-radius: 8px; + overflow: auto; +} + +.plugin-roles-table { + width: 100%; + border-spacing: 0; + border-collapse: collapse; +} + +.plugin-roles-table .mdc-data-table__table { + background-color: vars.$background-paper-elevation-1; +} + +.plugin-roles-table .mat-mdc-header-row, +.plugin-roles-table .mat-mdc-row, +.plugin-roles-table .mat-mdc-header-cell, +.plugin-roles-table .mat-mdc-cell { + background-color: vars.$background-paper-elevation-1; + color: vars.$text-primary; +} + +.plugin-roles-table .mat-mdc-row:hover { + background-color: rgba(vars.$primary-main, 0.12); +} + +.checkbox-indicator { + display: flex; + align-items: center; + justify-content: start; + + .check-icon { + color: vars.$success-main; + font-size: 20px; + } + + .x-icon { + color: vars.$error-light; + font-size: 20px; + } +} + +.loading-content { + p { + margin-top: 16px; + } +} + +:host ::ng-deep .plugin-roles-table .mdc-data-table__header-row, +:host ::ng-deep .plugin-roles-table .mdc-data-table__row, +:host ::ng-deep .plugin-roles-table .mdc-data-table__header-cell, +:host ::ng-deep .plugin-roles-table .mdc-data-table__cell { + background-color: vars.$background-paper-elevation-1 !important; + color: vars.$text-primary !important; +} diff --git a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.ts b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.ts new file mode 100644 index 00000000..da34a40d --- /dev/null +++ b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.ts @@ -0,0 +1,115 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTableModule } from '@angular/material/table'; +import { Subscription } from 'rxjs'; +import { + UsersAndPermissionsPluginService, + UsersAndPermissionsUserItem, +} from '../../services/auth/users-and-permissions-plugin.service'; +import { UsersManagementService } from '../../services/auth/users-management.service'; +import { MatDialog } from '@angular/material/dialog'; +import { InviteUserData, InviteUserDialogComponent } from '../../invite-user-dialog/invite-user-dialog.component'; + +@Component({ + selector: 'app-users-and-permissions-users', + standalone: true, + imports: [MatProgressSpinnerModule, MatTableModule, MatIconModule], + templateUrl: './users-and-permissions-plugin-users.component.html', + styleUrl: './users-and-permissions-plugin-users.component.scss', +}) +export class UsersAndPermissionsUsersComponent implements OnInit, OnDestroy { + private subscription = new Subscription(); + + inviteButtonText = '+ Invite Users'; + usersAndPermissionsUsers: UsersAndPermissionsUserItem[] = []; + usersAndPermissionsUserColumns: string[] = ['userName', 'email', 'roleName', 'emailConfirmed']; + usersAndPermissionsUsersLoading = false; + usersAndPermissionsUsersError = ''; + isEmailServiceAvailable = true; + inviteError = ''; + + + constructor( + private usersAndPermissionsPluginService: UsersAndPermissionsPluginService, + private usersManagementService: UsersManagementService, + private dialog: MatDialog + ) { } + + ngOnInit(): void { + this.loadUsersAndPermissionsUsers(); + this.loadPluginsState(); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + private loadUsersAndPermissionsUsers(): void { + this.usersAndPermissionsUsersLoading = true; + this.usersAndPermissionsUsersError = ''; + + this.subscription.add( + this.usersAndPermissionsPluginService.getAllUsers().subscribe({ + next: (users) => { + this.usersAndPermissionsUsers = users; + this.usersAndPermissionsUsersLoading = false; + }, + error: (error) => { + this.usersAndPermissionsUsers = []; + this.usersAndPermissionsUsersError = this.getApiErrorMessage(error); + this.usersAndPermissionsUsersLoading = false; + }, + }) + ); + } + + inviteUser(): void { + const dialogRef = this.dialog.open(InviteUserDialogComponent, { + data: { + isEmailServiceAvailable: this.isEmailServiceAvailable, + usePluginRoles: true, + }, + }); + + dialogRef.afterClosed().subscribe((data: InviteUserData | null) => { + if (!data) return; + + this.inviteError = ''; + + this.subscription.add( + this.usersAndPermissionsPluginService.inviteUser(data).subscribe({ + next: () => { + this.loadUsersAndPermissionsUsers(); + }, + error: (error) => { + const apiMessage = error?.error?.message || error?.error?.title; + this.inviteError = apiMessage || 'Failed to invite user.'; + }, + }) + ); + }); + } + + private getApiErrorMessage(error: any): string { + return ( + error?.error?.message || + error?.error?.title || + error?.error?.error || + (error?.status + ? `Failed to load users and permissions data (${error.status}${error?.statusText ? ` ${error.statusText}` : ''})${error?.url ? `: ${error.url}` : '.'}` + : 'Failed to load users and permissions data.') + ); + } + + private loadPluginsState(): void { + this.subscription.add( + this.usersManagementService.getPluginsState().subscribe({ + next: (response) => { + this.isEmailServiceAvailable = !!response.services?.['IEmailService']; + this.inviteButtonText = this.isEmailServiceAvailable ? '+ Invite Users' : 'Create User'; + }, + }) + ); + } +} diff --git a/Dappi.HeadlessCms/Exceptions/UserAlreadyExistsException.cs b/Dappi.Core/Exceptions/UserAlreadyExistsException.cs similarity index 77% rename from Dappi.HeadlessCms/Exceptions/UserAlreadyExistsException.cs rename to Dappi.Core/Exceptions/UserAlreadyExistsException.cs index 9ded6f04..4bb33492 100644 --- a/Dappi.HeadlessCms/Exceptions/UserAlreadyExistsException.cs +++ b/Dappi.Core/Exceptions/UserAlreadyExistsException.cs @@ -1,4 +1,4 @@ -namespace Dappi.HeadlessCms.Exceptions; +namespace Dappi.Core.Exceptions; public class UserAlreadyExistsException : Exception { diff --git a/Dappi.Core/Interfaces/IEmailSerevice.cs b/Dappi.Core/Interfaces/IEmailSerevice.cs new file mode 100644 index 00000000..ea982151 --- /dev/null +++ b/Dappi.Core/Interfaces/IEmailSerevice.cs @@ -0,0 +1,22 @@ +public interface IEmailService +{ + Task SendEmailAsync( + List toAddresses, + string bodyHtml, + string bodyText, + string subject + ); + bool VerifyEmailIdentityAsync(string mail); + Task CreateEmailTemplateAsync( + string name, + object templateModel, + string defaultSubjectTemplate, + string defaultTextTemplate, + string defaultHtmlTemplate + ); + Task SendTemplatedEmailAsync( + List toAddresses, + string userName, + string templateName + ); +} \ No newline at end of file diff --git a/Dappi.Core/Invitation/InvitationEmail.html b/Dappi.Core/Invitation/InvitationEmail.html new file mode 100644 index 00000000..4548edd7 --- /dev/null +++ b/Dappi.Core/Invitation/InvitationEmail.html @@ -0,0 +1,5 @@ +

Hi {{ username }},

+

You've been invited to join Dappi.

+

Temporary password: {{ temporary_password }}

+

Accept invitation

+

This link expires in {{ expiry_hours }} hours.

diff --git a/Dappi.Core/Invitation/InvitationEmail.txt b/Dappi.Core/Invitation/InvitationEmail.txt new file mode 100644 index 00000000..fe8ca900 --- /dev/null +++ b/Dappi.Core/Invitation/InvitationEmail.txt @@ -0,0 +1,9 @@ +Hi {{ username }}, + +You've been invited to join Dappi. +Temporary password: {{ temporary_password }} + +Accept your invitation here: +{{ accept_url }} + +This link expires in {{ expiry_hours }} hours. diff --git a/Dappi.HeadlessCms.UsersAndPermissions/Api/Dtos.cs b/Dappi.HeadlessCms.UsersAndPermissions/Api/Dtos.cs index 6c05af2b..17d75b6e 100644 --- a/Dappi.HeadlessCms.UsersAndPermissions/Api/Dtos.cs +++ b/Dappi.HeadlessCms.UsersAndPermissions/Api/Dtos.cs @@ -16,3 +16,21 @@ public class RegisterUserDto public required string Email { get; set; } public required string Password { get; set; } } + +public class InviteUserDto +{ + public required string Username { get; set; } + public required string Email { get; set; } + public string? Password { get; set; } + public List Roles { get; set; } = []; +} + +public class PluginUserDto +{ + public int Id { get; set; } + public string UserName { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public bool EmailConfirmed { get; set; } + public int RoleId { get; set; } + public string RoleName { get; set; } = string.Empty; +} diff --git a/Dappi.HeadlessCms.UsersAndPermissions/Api/UsersAndPermissionsController.cs b/Dappi.HeadlessCms.UsersAndPermissions/Api/UsersAndPermissionsController.cs index 890b52b7..65881a3a 100644 --- a/Dappi.HeadlessCms.UsersAndPermissions/Api/UsersAndPermissionsController.cs +++ b/Dappi.HeadlessCms.UsersAndPermissions/Api/UsersAndPermissionsController.cs @@ -1,7 +1,11 @@ +using Dappi.Core.Exceptions; using Dappi.HeadlessCms.UsersAndPermissions.Core; using Dappi.HeadlessCms.UsersAndPermissions.Core.Exceptions; using Dappi.HeadlessCms.UsersAndPermissions.Database; +using Dappi.HeadlessCms.UsersAndPermissions.Interfaces; +using Dappi.HeadlessCms.UsersAndPermissions.Models; using Dappi.HeadlessCms.UsersAndPermissions.Services; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -14,13 +18,254 @@ public class UsersAndPermissionsController( IDbContextAccessor usersAndPermissionsDb, AvailablePermissionsRepository availablePermissionsRepository, UserManager userManager, - TokenService tokenService -) : ControllerBase + TokenService tokenService, + IEmailService? service, + IInvitationService invitationService) : ControllerBase where TUser : AppUser, new() { private readonly UsersAndPermissionsDbContext _usersAndPermissionsDb = usersAndPermissionsDb.DbContext; + + [HttpPost] + public async Task InviteUser([FromBody] InviteUserDto dto) + { + var existingUser = await userManager.Users.FirstOrDefaultAsync(user => + (user.UserName ?? string.Empty).ToLower() == dto.Username.ToLower() || + (user.Email ?? string.Empty).ToLower() == dto.Email.ToLower()); + + if (existingUser is not null) + { + if (string.Equals(existingUser.UserName, dto.Username, StringComparison.OrdinalIgnoreCase)) + { + throw new UserAlreadyExistsException("An account already exists with conflicting username"); + } + + if (string.Equals(existingUser.Email, dto.Email, StringComparison.OrdinalIgnoreCase)) + { + throw new UserAlreadyExistsException("An account already exists with conflicting email"); + } + } + + InvitationPreparationResult? invitation = null; + var passwordToUse = dto.Password; + var isInvitationFlow = service is not null && string.IsNullOrWhiteSpace(passwordToUse); + + if (isInvitationFlow) + { + var requestBaseUrl = $"{Request.Scheme}://{Request.Host}"; + invitation = await invitationService.PrepareInvitationAsync(dto, requestBaseUrl); + passwordToUse = invitation.GeneratedPassword; + } + else if (string.IsNullOrWhiteSpace(passwordToUse)) + { + return BadRequest(new + { + message = "Password is required to create a user.", + }); + } + + var rolesToAssign = dto.Roles.Count > 0 + ? dto.Roles + : new List { UsersAndPermissionsConstants.DefaultRoles.Authenticated }; + + var normalizedRequestedRoles = rolesToAssign + .Where(role => !string.IsNullOrWhiteSpace(role)) + .Select(role => role.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (normalizedRequestedRoles.Count == 0) + { + normalizedRequestedRoles = [UsersAndPermissionsConstants.DefaultRoles.Authenticated]; + } + + var availableRoles = await _usersAndPermissionsDb.AppRoles.ToListAsync(); + var assignedRole = availableRoles.FirstOrDefault(role => + normalizedRequestedRoles.Any(requested => + string.Equals(requested, role.Name, StringComparison.OrdinalIgnoreCase) + ) + ); + if (assignedRole is null) + { + return BadRequest(new + { + message = "At least one valid role is required for invitation.", + }); + } + + var user = new TUser + { + UserName = dto.Username, + Email = dto.Email, + EmailConfirmed = !isInvitationFlow, + RoleId = assignedRole.Id, + }; + + var result = await userManager.CreateAsync(user, passwordToUse!); + + if (!result.Succeeded) + { + return BadRequest(new + { + message = result.Errors.FirstOrDefault()?.Description ?? "Failed to create invited user.", + }); + } + + if (!isInvitationFlow) + { + return Ok(new + { + message = "User created successfully.", + }); + } + + if (invitation is null) + { + return BadRequest(new + { + message = "Failed to prepare invitation.", + }); + } + + var emailService = service; + if (emailService is null) + { + return BadRequest(new + { + message = "Email service is not configured.", + }); + } + + var messageId = await emailService.SendEmailAsync( + [dto.Email], + invitation.EmailHtmlBody, + invitation.EmailTextBody, + invitation.EmailSubject + ); + + return Ok(new + { + message = "Invitation sent successfully.", + emailSent = true, + messageId, + invitationLink = invitation.AcceptUrl, + frontendInvitationLink = invitation.FrontendAcceptUrl, + fallbackApiAcceptLink = invitation.AcceptUrl + }); + } + + [AllowAnonymous] + [HttpGet("accept-invitation")] + public async Task AcceptInvitation([FromQuery] AcceptInvitationQueryDto dto) + { + if (!invitationService.TryGetInvitationPayload(dto.Token, out var invitation, out var tokenError)) + { + return BadRequest(new { message = tokenError }); + } + + if (invitation.ExpiresAtUtc < DateTime.UtcNow) + { + return BadRequest(new { message = "Invitation token has expired." }); + } + + var existingUsers = await userManager.Users + .Where(user => + (user.UserName ?? string.Empty).ToLower() == invitation.Username.ToLower() || + (user.Email ?? string.Empty).ToLower() == invitation.Email.ToLower()) + .Take(2) + .ToListAsync(); + + if (existingUsers.Count > 1) + { + return BadRequest(new + { + message = "An invitation-related account already exists with conflicting data.", + }); + } + + var existingUser = existingUsers.FirstOrDefault(); + + if (existingUser is null) + { + return BadRequest(new { message = "Invitation-related user account was not found." }); + } + + var requestBaseUrl = $"{Request.Scheme}://{Request.Host}"; + var completeInvitationUrl = invitationService.BuildCompleteInvitationUrl(requestBaseUrl, dto.Token); + + return Redirect(completeInvitationUrl); + } + + [AllowAnonymous] + [HttpPost("complete-invitation")] + public async Task CompleteInvitation([FromBody] CompleteInvitationDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Token)) + { + return BadRequest(new { message = "Invitation token is required." }); + } + + if (string.IsNullOrWhiteSpace(dto.OldPassword) || string.IsNullOrWhiteSpace(dto.NewPassword)) + { + return BadRequest(new { message = "Both old and new passwords are required." }); + } + + if (!invitationService.TryGetInvitationPayload(dto.Token, out var invitation, out var tokenError)) + { + return BadRequest(new { message = tokenError }); + } + + if (invitation.ExpiresAtUtc < DateTime.UtcNow) + { + return BadRequest(new { message = "Invitation token has expired." }); + } + + var user = await userManager.FindByNameAsync(invitation.Username); + if (user is null || !string.Equals(user.Email, invitation.Email, StringComparison.OrdinalIgnoreCase)) + { + return BadRequest(new { message = "Invitation is not accepted yet." }); + } + + var passwordMatches = await userManager.CheckPasswordAsync(user, dto.OldPassword); + if (!passwordMatches) + { + return BadRequest(new { message = "Old password is incorrect." }); + } + + var changePasswordResult = await userManager.ChangePasswordAsync( + user, + dto.OldPassword, + dto.NewPassword + ); + + if (!changePasswordResult.Succeeded) + { + return BadRequest(new + { + message = changePasswordResult.Errors.FirstOrDefault()?.Description ?? "Failed to change password.", + }); + } + + if (!user.EmailConfirmed) + { + user.EmailConfirmed = true; + } + + var updateResult = await userManager.UpdateAsync(user); + if (!updateResult.Succeeded) + { + return BadRequest(new + { + message = updateResult.Errors.FirstOrDefault()?.Description ?? "Password changed, but failed to finalize invitation.", + }); + } + + return Ok(new { message = "Password changed successfully. User verified." }); + } + + + [HttpPost(UsersAndPermissionsConstants.AuthenticationRoutes.Register)] public async Task Register([FromBody] RegisterUserDto userDto) { @@ -82,7 +327,10 @@ CancellationToken cancellationToken var role = await _usersAndPermissionsDb .AppRoles.Include(r => r.Permissions) - .FirstOrDefaultAsync(r => r.Name == roleName, cancellationToken); + .FirstOrDefaultAsync( + r => r.Name.ToLower() == roleName.Trim().ToLower(), + cancellationToken + ); if (role is null) return NotFound($"Role '{roleName}' not found."); @@ -128,7 +376,7 @@ CancellationToken cancellationToken return Ok(result); } - + [HttpGet("roles")] public async Task GetAllRoles() { @@ -142,4 +390,23 @@ public async Task GetAllRoles() .ToListAsync(); return Ok(roles); } + + [HttpGet("users")] + public async Task GetAllUsers(CancellationToken cancellationToken) + { + var users = await userManager + .Users.Include(u => u.Role) + .Select(u => new PluginUserDto + { + Id = u.Id, + UserName = u.UserName ?? string.Empty, + Email = u.Email ?? string.Empty, + EmailConfirmed = u.EmailConfirmed, + RoleId = u.RoleId, + RoleName = u.Role != null ? u.Role.Name : string.Empty, + }) + .ToListAsync(cancellationToken); + + return Ok(users); + } } diff --git a/Dappi.HeadlessCms.UsersAndPermissions/Extensions.cs b/Dappi.HeadlessCms.UsersAndPermissions/Extensions.cs index b8cd5038..45dece13 100644 --- a/Dappi.HeadlessCms.UsersAndPermissions/Extensions.cs +++ b/Dappi.HeadlessCms.UsersAndPermissions/Extensions.cs @@ -1,6 +1,4 @@ using System.Reflection; -using System.Text; -using System.Text.Json; using System.Text.Json.Serialization; using Dappi.Core.Models; using Dappi.HeadlessCms.UsersAndPermissions.Api; @@ -9,16 +7,17 @@ using Dappi.HeadlessCms.UsersAndPermissions.Api.Middleware; using Dappi.HeadlessCms.UsersAndPermissions.Core; using Dappi.HeadlessCms.UsersAndPermissions.Database; +using Dappi.HeadlessCms.UsersAndPermissions.Interfaces; using Dappi.HeadlessCms.UsersAndPermissions.Jwt; using Dappi.HeadlessCms.UsersAndPermissions.Services; +using Dappi.HeadlessCms.UsersAndPermissions.Services.Identity; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.IdentityModel.Tokens; using Npgsql; namespace Dappi.HeadlessCms.UsersAndPermissions; @@ -47,7 +46,10 @@ IConfiguration configuration services.AddSingleton(new AvailablePermissionsRepository(controllerRoutes)); services.AddMemoryCache(); services - .AddIdentityCore() + .AddIdentityCore(options => + { + options.SignIn.RequireConfirmedEmail = true; + }) .AddRoles() .AddEntityFrameworkStores() .AddDefaultTokenProviders() @@ -68,6 +70,8 @@ IConfiguration configuration } ); services.AddScoped>(); + services.AddDataProtection(); + services.AddScoped(); services.AddScoped(); services.AddScoped>(); diff --git a/Dappi.HeadlessCms.UsersAndPermissions/Interfaces/IInvitationService.cs b/Dappi.HeadlessCms.UsersAndPermissions/Interfaces/IInvitationService.cs new file mode 100644 index 00000000..4ea5bf36 --- /dev/null +++ b/Dappi.HeadlessCms.UsersAndPermissions/Interfaces/IInvitationService.cs @@ -0,0 +1,13 @@ +using Dappi.HeadlessCms.UsersAndPermissions.Api; +using Dappi.HeadlessCms.UsersAndPermissions.Models; + +namespace Dappi.HeadlessCms.UsersAndPermissions.Interfaces; + +public interface IInvitationService +{ + Task PrepareInvitationAsync(InviteUserDto dto, string requestBaseUrl); + + bool TryGetInvitationPayload(string token, out InvitationPayload invitation, out string error); + + string BuildCompleteInvitationUrl(string requestBaseUrl, string token); +} diff --git a/Dappi.HeadlessCms.UsersAndPermissions/Models/AuthDtos.cs b/Dappi.HeadlessCms.UsersAndPermissions/Models/AuthDtos.cs new file mode 100644 index 00000000..de89d03e --- /dev/null +++ b/Dappi.HeadlessCms.UsersAndPermissions/Models/AuthDtos.cs @@ -0,0 +1,31 @@ +namespace Dappi.HeadlessCms.UsersAndPermissions.Models; + +public sealed record InvitationPreparationResult( + string Token, + string GeneratedPassword, + string AcceptUrl, + string? FrontendAcceptUrl, + string EmailSubject, + string EmailTextBody, + string EmailHtmlBody +); + +public class CompleteInvitationDto +{ + public required string Token { get; set; } + public required string OldPassword { get; set; } + public required string NewPassword { get; set; } +} + +public class AcceptInvitationQueryDto +{ + public string Token { get; set; } = string.Empty; +} + +public sealed record InvitationPayload( + string Username, + string Email, + string Password, + List Roles, + DateTime ExpiresAtUtc +); \ No newline at end of file diff --git a/Dappi.HeadlessCms.UsersAndPermissions/Services/Identity/InvitationService.cs b/Dappi.HeadlessCms.UsersAndPermissions/Services/Identity/InvitationService.cs new file mode 100644 index 00000000..429026b8 --- /dev/null +++ b/Dappi.HeadlessCms.UsersAndPermissions/Services/Identity/InvitationService.cs @@ -0,0 +1,233 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Dappi.HeadlessCms.UsersAndPermissions.Api; +using Dappi.HeadlessCms.UsersAndPermissions.Core; +using Dappi.HeadlessCms.UsersAndPermissions.Interfaces; +using Dappi.HeadlessCms.UsersAndPermissions.Models; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Configuration; + +namespace Dappi.HeadlessCms.UsersAndPermissions.Services.Identity; + +public class InvitationService( + IConfiguration configuration, + IDataProtectionProvider dataProtectionProvider, + IEmailService? emailService = null +) : IInvitationService +{ + private const string DefaultInvitationEmailSubjectTemplate = "You're invited to join Dappi"; + private const string DefaultInvitationEmailHtmlTemplatePath = "Invitation/InvitationEmail.html"; + private const string DefaultInvitationEmailTextTemplatePath = "Invitation/InvitationEmail.txt"; + + private readonly IDataProtector _invitationProtector = dataProtectionProvider.CreateProtector( + "Dappi.HeadlessCms.UsersAndPermissions.Invitation.v1" + ); + + public async Task PrepareInvitationAsync( + InviteUserDto dto, + string requestBaseUrl + ) + { + var defaultInvitationEmailHtmlTemplate = ReadTemplateFile( + DefaultInvitationEmailHtmlTemplatePath, + "

Hi {{ username }},

You've been invited to join Dappi.

Temporary password: {{ temporary_password }}

Accept invitation

This link expires in {{ expiry_hours }} hours.

" + ); + var defaultInvitationEmailTextTemplate = ReadTemplateFile( + DefaultInvitationEmailTextTemplatePath, + "Hi {{ username }},\n\nYou've been invited to join Dappi.\nTemporary password: {{ temporary_password }}\n\nAccept your invitation here:\n{{ accept_url }}\n\nThis link expires in {{ expiry_hours }} hours." + ); + + var rolesToAssign = dto.Roles.Count > 0 + ? dto.Roles + : [UsersAndPermissionsConstants.DefaultRoles.Authenticated]; + var generatedPassword = string.IsNullOrWhiteSpace(dto.Password) + ? GeneratePassword() + : dto.Password!; + + var invitationPayload = new InvitationPayload( + dto.Username, + dto.Email, + generatedPassword, + rolesToAssign, + DateTime.UtcNow.AddDays(2) + ); + + var protectedToken = _invitationProtector.Protect(JsonSerializer.Serialize(invitationPayload)); + var token = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(protectedToken)); + + var acceptancePath = $"/api/usersandpermissions/accept-invitation?token={token}"; + var acceptUrl = $"{requestBaseUrl}{acceptancePath}"; + + var frontendUrl = configuration.GetValue("Dappi:FrontendUrl"); + var frontendAcceptUrl = string.IsNullOrWhiteSpace(frontendUrl) + ? null + : $"{frontendUrl.TrimEnd('/')}/accept-invitation?token={token}"; + + var replacements = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["username"] = dto.Username, + ["temporary_password"] = generatedPassword, + ["accept_url"] = acceptUrl, + ["expiry_hours"] = "48", + }; + + var configuredSubjectTemplate = configuration.GetValue( + "Dappi:InvitationEmailSubjectTemplate" + ); + var configuredTextTemplate = configuration.GetValue( + "Dappi:InvitationEmailTextTemplate" + ); + var configuredHtmlTemplate = configuration.GetValue( + "Dappi:InvitationEmailHtmlTemplate" + ); + + var emailSubject = RenderTemplate( + configuredSubjectTemplate, + DefaultInvitationEmailSubjectTemplate, + replacements + ); + var emailTextBody = RenderTemplate( + configuredTextTemplate, + defaultInvitationEmailTextTemplate, + replacements + ); + var emailHtmlBody = RenderTemplate( + configuredHtmlTemplate, + defaultInvitationEmailHtmlTemplate, + replacements + ); + + if (emailService is not null) + { + var reusableTemplateModel = new + { + username = "{{username}}", + temporary_password = "{{temporary_password}}", + accept_url = "{{accept_url}}", + expiry_hours = "{{expiry_hours}}", + }; + + await emailService.CreateEmailTemplateAsync( + "InviteUser", + reusableTemplateModel, + DefaultInvitationEmailSubjectTemplate, + defaultInvitationEmailTextTemplate, + defaultInvitationEmailHtmlTemplate + ); + } + + return new InvitationPreparationResult( + token, + generatedPassword, + acceptUrl, + frontendAcceptUrl, + emailSubject, + emailTextBody, + emailHtmlBody + ); + } + + public bool TryGetInvitationPayload(string token, out InvitationPayload invitation, out string error) + { + try + { + var decodedTokenBytes = WebEncoders.Base64UrlDecode(token); + var protectedToken = Encoding.UTF8.GetString(decodedTokenBytes); + var unprotectedPayload = _invitationProtector.Unprotect(protectedToken); + var parsedInvitation = JsonSerializer.Deserialize(unprotectedPayload); + + if (parsedInvitation is null) + { + invitation = default!; + error = "Invitation payload is invalid."; + return false; + } + + invitation = parsedInvitation; + error = string.Empty; + return true; + } + catch + { + invitation = default!; + error = "Invitation token is invalid."; + return false; + } + } + + public string BuildCompleteInvitationUrl(string requestBaseUrl, string token) + { + var frontendUrl = configuration.GetValue("Dappi:FrontendUrl"); + var baseUrl = string.IsNullOrWhiteSpace(frontendUrl) + ? requestBaseUrl.TrimEnd('/') + : frontendUrl.TrimEnd('/'); + + return $"{baseUrl}/complete-invitation?token={token}&flow=usersandpermissions"; + } + + private static string RenderTemplate( + string? configuredTemplate, + string fallbackTemplate, + IReadOnlyDictionary replacements + ) + { + var template = string.IsNullOrWhiteSpace(configuredTemplate) + ? fallbackTemplate + : configuredTemplate; + + foreach (var (key, value) in replacements) + { + template = template.Replace($"{{{{ {key} }}}}", value, StringComparison.OrdinalIgnoreCase); + template = template.Replace($"{{{{{key}}}}}", value, StringComparison.OrdinalIgnoreCase); + } + + return template; + } + + private static string ReadTemplateFile(string relativePath, string fallbackTemplate) + { + var templatePath = Path.Combine(AppContext.BaseDirectory, relativePath); + return File.Exists(templatePath) ? File.ReadAllText(templatePath) : fallbackTemplate; + } + + private static string GeneratePassword() + { + const string lower = "abcdefghijklmnopqrstuvwxyz"; + const string upper = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const string digits = "0123456789"; + const string symbols = "!@#$%^&*()_-+=[]{}<>?"; + + var requiredCharacters = new List + { + GetRandomCharacter(lower), + GetRandomCharacter(upper), + GetRandomCharacter(digits), + GetRandomCharacter(symbols), + }; + + var allCharacters = lower + upper + digits + symbols; + const int totalLength = 12; + + while (requiredCharacters.Count < totalLength) + { + requiredCharacters.Add(GetRandomCharacter(allCharacters)); + } + + for (var index = requiredCharacters.Count - 1; index > 0; index--) + { + var swapIndex = RandomNumberGenerator.GetInt32(index + 1); + (requiredCharacters[index], requiredCharacters[swapIndex]) = + (requiredCharacters[swapIndex], requiredCharacters[index]); + } + + return new string(requiredCharacters.ToArray()); + } + + private static char GetRandomCharacter(string source) + { + var position = RandomNumberGenerator.GetInt32(source.Length); + return source[position]; + } +} diff --git a/Dappi.HeadlessCms.UsersAndPermissions/Services/MailServices/AwsSesService.cs b/Dappi.HeadlessCms.UsersAndPermissions/Services/MailServices/AwsSesService.cs new file mode 100644 index 00000000..e69de29b diff --git a/Dappi.HeadlessCms/Controllers/PluginsController.cs b/Dappi.HeadlessCms/Controllers/PluginsController.cs index ebe66516..ab69bdf7 100644 --- a/Dappi.HeadlessCms/Controllers/PluginsController.cs +++ b/Dappi.HeadlessCms/Controllers/PluginsController.cs @@ -22,9 +22,10 @@ public PluginsController(IServiceProvider serviceProvider) public IActionResult GetPluginsState() { var interfaceTypes = new[] { typeof(IEmailService) }; + var availableInterfaces = _serviceProvider.ResolveAvailable(interfaceTypes); var services = interfaceTypes.ToDictionary( - type => char.ToLowerInvariant(type.Name[0]) + type.Name.Substring(1), + type => type.Name, type => availableInterfaces.ContainsKey(type) ); diff --git a/Dappi.HeadlessCms/Controllers/UsersController.cs b/Dappi.HeadlessCms/Controllers/UsersController.cs index af2b55c4..9f7fef42 100644 --- a/Dappi.HeadlessCms/Controllers/UsersController.cs +++ b/Dappi.HeadlessCms/Controllers/UsersController.cs @@ -1,5 +1,5 @@ using Dappi.HeadlessCms.Authentication; -using Dappi.HeadlessCms.Exceptions; +using Dappi.Core.Exceptions; using Dappi.HeadlessCms.Interfaces; using Dappi.HeadlessCms.Models; using Microsoft.AspNetCore.Authorization; @@ -123,7 +123,17 @@ public async Task InviteUser([FromBody] InviteUserDto dto) }); } - var messageId = await _emailService.SendEmailAsync( + + var emailService = _emailService; + if (emailService is null) + { + return BadRequest(new + { + message = "Email service is not configured.", + }); + } + + var messageId = await emailService.SendEmailAsync( [dto.Email], invitation.EmailHtmlBody, invitation.EmailTextBody, diff --git a/Dappi.HeadlessCms/Dappi.HeadlessCms.csproj b/Dappi.HeadlessCms/Dappi.HeadlessCms.csproj index 311f1853..ea387926 100644 --- a/Dappi.HeadlessCms/Dappi.HeadlessCms.csproj +++ b/Dappi.HeadlessCms/Dappi.HeadlessCms.csproj @@ -27,10 +27,12 @@ - + + Invitation/InvitationEmail.html PreserveNewest - + + Invitation/InvitationEmail.txt PreserveNewest @@ -47,11 +49,13 @@ true true - + + Invitation/InvitationEmail.html true true - + + Invitation/InvitationEmail.txt true true diff --git a/Dappi.HeadlessCms/Interfaces/IEmailService.cs b/Dappi.HeadlessCms/Interfaces/IEmailService.cs deleted file mode 100644 index 3f64a4c6..00000000 --- a/Dappi.HeadlessCms/Interfaces/IEmailService.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace Dappi.HeadlessCms.Interfaces -{ - public interface IEmailService - { - Task SendEmailAsync( - List toAddresses, - string bodyHtml, - string bodyText, - string subject - ); - bool VerifyEmailIdentityAsync(string mail); - Task CreateEmailTemplateAsync( - string name, - object templateModel, - string defaultSubjectTemplate, - string defaultTextTemplate, - string defaultHtmlTemplate - ); - Task SendTemplatedEmailAsync( - List toAddresses, - string userName, - string templateName - ); - } -} diff --git a/Dappi.HeadlessCms/Middleware/ExceptionHandlingMiddleware.cs b/Dappi.HeadlessCms/Middleware/ExceptionHandlingMiddleware.cs index e260df14..73731dde 100644 --- a/Dappi.HeadlessCms/Middleware/ExceptionHandlingMiddleware.cs +++ b/Dappi.HeadlessCms/Middleware/ExceptionHandlingMiddleware.cs @@ -1,4 +1,5 @@ using System.Net; +using Dappi.Core.Exceptions; using Dappi.HeadlessCms.Exceptions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; diff --git a/Dappi.HeadlessCms/ServiceExtensions.cs b/Dappi.HeadlessCms/ServiceExtensions.cs index bf2c4491..6d33197f 100644 --- a/Dappi.HeadlessCms/ServiceExtensions.cs +++ b/Dappi.HeadlessCms/ServiceExtensions.cs @@ -1,8 +1,6 @@ -using System.Reflection; using System.Text; using System.Text.Json.Serialization; using Dappi.Core.Abstractions.Auth; -using Dappi.HeadlessCms.ActionFilters; using Dappi.HeadlessCms.Authentication; using Dappi.HeadlessCms.Background; using Dappi.HeadlessCms.Core; @@ -180,6 +178,7 @@ public static IServiceCollection AddDappiAuthentication( .AddIdentity(options => { options.User.RequireUniqueEmail = true; + options.SignIn.RequireConfirmedEmail = true; options.Password.RequireDigit = true; options.Password.RequireLowercase = true; @@ -335,6 +334,8 @@ public static IServiceCollection AddDappiSwaggerGen(this IServiceCollection serv { return services.AddSwaggerGen(c => { + c.CustomSchemaIds(type => (type.FullName ?? type.Name).Replace("+", ".")); + c.SwaggerDoc("Toolkit", new OpenApiInfo { Title = "Toolkit API", Version = "v1" }); c.SwaggerDoc("Default", new OpenApiInfo { Title = "Default API", Version = "v1" }); @@ -376,7 +377,7 @@ public static IServiceCollection AddDappiSwaggerGen(this IServiceCollection serv }, } ); - c.TagActionsBy(api => api.GroupName ?? api.ActionDescriptor.RouteValues["controller"]); + c.TagActionsBy(api => [api.GroupName ?? api.ActionDescriptor.RouteValues["controller"] ?? "Default"]); }); } } diff --git a/Dappi.HeadlessCms/Services/Identity/InvitationService.cs b/Dappi.HeadlessCms/Services/Identity/InvitationService.cs index 6c15acb6..238bae8d 100644 --- a/Dappi.HeadlessCms/Services/Identity/InvitationService.cs +++ b/Dappi.HeadlessCms/Services/Identity/InvitationService.cs @@ -13,8 +13,8 @@ namespace Dappi.HeadlessCms.Services.Identity; public class InvitationService : IInvitationService { private const string InviteUserTemplateName = "InviteUser"; - private const string DefaultInvitationEmailHtmlTemplatePath = "EmailTemplates/Invitation/InvitationEmail.html"; - private const string DefaultInvitationEmailTextTemplatePath = "EmailTemplates/Invitation/InvitationEmail.txt"; + private const string DefaultInvitationEmailHtmlTemplatePath = "Invitation/InvitationEmail.html"; + private const string DefaultInvitationEmailTextTemplatePath = "Invitation/InvitationEmail.txt"; private const string DefaultInvitationEmailSubjectTemplate = "You're invited to join Dappi"; private readonly IConfiguration _configuration; diff --git a/templates/MyCompany.MyProject.WebApi/MyCompany.MyProject.WebApi.csproj b/templates/MyCompany.MyProject.WebApi/MyCompany.MyProject.WebApi.csproj index 1d59a317..5e72c61c 100644 --- a/templates/MyCompany.MyProject.WebApi/MyCompany.MyProject.WebApi.csproj +++ b/templates/MyCompany.MyProject.WebApi/MyCompany.MyProject.WebApi.csproj @@ -11,7 +11,10 @@ - + + + + diff --git a/templates/MyCompany.MyProject.WebApi/Program.cs b/templates/MyCompany.MyProject.WebApi/Program.cs index c9583906..e0bac0dd 100644 --- a/templates/MyCompany.MyProject.WebApi/Program.cs +++ b/templates/MyCompany.MyProject.WebApi/Program.cs @@ -1,6 +1,10 @@ using Dappi.HeadlessCms; using Dappi.HeadlessCms.Models; +using Dappi.HeadlessCms.UsersAndPermissions; +using GeneratedPermissions; using MyCompany.MyProject.WebApi.Data; +using MyCompany.MyProject.WebApi.UsersAndPermissionsSystem.Data; +using AppUser = MyCompany.MyProject.WebApi.UsersAndPermissionsSystem.Entities.AppUser; namespace MyCompany.MyProject.WebApi; @@ -11,16 +15,22 @@ private static async Task Main(string[] args) var builder = WebApplication.CreateBuilder(args); builder.Services.AddDappi(builder.Configuration); + builder.Services.AddAwsSes(builder.Configuration); builder.Services.AddDappiAuthentication(builder.Configuration); + builder.Services.AddUsersAndPermissionsSystem( + PermissionsMeta.Controllers, + builder.Configuration + ); var app = builder.Build(); + await app.UseUsersAndPermissionsSystem( + typeof(Program).Assembly + ); + await app.UseDappi(); - - app.UseHttpsRedirection(); - app.MapControllers(); - + app.Run(); } } diff --git a/templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/20260318142256_InitialUsersAndPermissions.Designer.cs b/templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/20260318142256_InitialUsersAndPermissions.Designer.cs new file mode 100644 index 00000000..d23081b5 --- /dev/null +++ b/templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/20260318142256_InitialUsersAndPermissions.Designer.cs @@ -0,0 +1,237 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyCompany.MyProject.WebApi.UsersAndPermissionsSystem.Data; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MyCompany.MyProject.WebApi.UsersAndPermissionsSystem.Migrations +{ + [DbContext(typeof(AppUsersAndPermissionsDbContext))] + [Migration("20260318142256_InitialUsersAndPermissions")] + partial class InitialUsersAndPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("UsersAndPermissions") + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppPermissionAppRole", b => + { + b.Property("PermissionsId") + .HasColumnType("integer"); + + b.Property("RolesId") + .HasColumnType("integer"); + + b.HasKey("PermissionsId", "RolesId"); + + b.HasIndex("RolesId"); + + b.ToTable("AppPermissionAppRole", "UsersAndPermissions"); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AppPermissions", "UsersAndPermissions"); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("IsDefaultForAuthenticatedUser") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AppRoles", "UsersAndPermissions"); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AppUser", "UsersAndPermissions"); + + b.HasDiscriminator().HasValue("AppUser"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("ReplacedByToken") + .HasColumnType("text"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", "UsersAndPermissions"); + }); + + modelBuilder.Entity("MyCompany.MyProject.WebApi.UsersAndPermissionsSystem.Entities.AppUser", b => + { + b.HasBaseType("Dappi.HeadlessCms.UsersAndPermissions.Core.AppUser"); + + b.HasDiscriminator().HasValue("AppUser"); + }); + + modelBuilder.Entity("AppPermissionAppRole", b => + { + b.HasOne("Dappi.HeadlessCms.UsersAndPermissions.Core.AppPermission", null) + .WithMany() + .HasForeignKey("PermissionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Dappi.HeadlessCms.UsersAndPermissions.Core.AppRole", null) + .WithMany() + .HasForeignKey("RolesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppUser", b => + { + b.HasOne("Dappi.HeadlessCms.UsersAndPermissions.Core.AppRole", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppRole", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/20260318142256_InitialUsersAndPermissions.cs b/templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/20260318142256_InitialUsersAndPermissions.cs new file mode 100644 index 00000000..1fefc0e4 --- /dev/null +++ b/templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/20260318142256_InitialUsersAndPermissions.cs @@ -0,0 +1,182 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MyCompany.MyProject.WebApi.UsersAndPermissionsSystem.Migrations +{ + /// + public partial class InitialUsersAndPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "UsersAndPermissions"); + + migrationBuilder.CreateTable( + name: "AppPermissions", + schema: "UsersAndPermissions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "text", nullable: false), + Description = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppPermissions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AppRoles", + schema: "UsersAndPermissions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "text", nullable: false), + IsDefaultForAuthenticatedUser = table.Column(type: "boolean", nullable: false), + NormalizedName = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AppRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RefreshTokens", + schema: "UsersAndPermissions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Token = table.Column(type: "text", nullable: false), + UserId = table.Column(type: "integer", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + IsRevoked = table.Column(type: "boolean", nullable: false), + ReplacedByToken = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RefreshTokens", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AppPermissionAppRole", + schema: "UsersAndPermissions", + columns: table => new + { + PermissionsId = table.Column(type: "integer", nullable: false), + RolesId = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppPermissionAppRole", x => new { x.PermissionsId, x.RolesId }); + table.ForeignKey( + name: "FK_AppPermissionAppRole_AppPermissions_PermissionsId", + column: x => x.PermissionsId, + principalSchema: "UsersAndPermissions", + principalTable: "AppPermissions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AppPermissionAppRole_AppRoles_RolesId", + column: x => x.RolesId, + principalSchema: "UsersAndPermissions", + principalTable: "AppRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AppUser", + schema: "UsersAndPermissions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "integer", nullable: false), + Discriminator = table.Column(type: "character varying(8)", maxLength: 8, nullable: false), + UserName = table.Column(type: "text", nullable: true), + NormalizedUserName = table.Column(type: "text", nullable: true), + Email = table.Column(type: "text", nullable: true), + NormalizedEmail = table.Column(type: "text", nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AppUser", x => x.Id); + table.ForeignKey( + name: "FK_AppUser_AppRoles_RoleId", + column: x => x.RoleId, + principalSchema: "UsersAndPermissions", + principalTable: "AppRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AppPermissionAppRole_RolesId", + schema: "UsersAndPermissions", + table: "AppPermissionAppRole", + column: "RolesId"); + + migrationBuilder.CreateIndex( + name: "IX_AppUser_RoleId", + schema: "UsersAndPermissions", + table: "AppUser", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_Token", + schema: "UsersAndPermissions", + table: "RefreshTokens", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RefreshTokens_UserId", + schema: "UsersAndPermissions", + table: "RefreshTokens", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AppPermissionAppRole", + schema: "UsersAndPermissions"); + + migrationBuilder.DropTable( + name: "AppUser", + schema: "UsersAndPermissions"); + + migrationBuilder.DropTable( + name: "RefreshTokens", + schema: "UsersAndPermissions"); + + migrationBuilder.DropTable( + name: "AppPermissions", + schema: "UsersAndPermissions"); + + migrationBuilder.DropTable( + name: "AppRoles", + schema: "UsersAndPermissions"); + } + } +} diff --git a/templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/AppUsersAndPermissionsDbContextModelSnapshot.cs b/templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/AppUsersAndPermissionsDbContextModelSnapshot.cs new file mode 100644 index 00000000..49a353ce --- /dev/null +++ b/templates/MyCompany.MyProject.WebApi/UsersAndPermissionsSystem/Migrations/AppUsersAndPermissionsDbContextModelSnapshot.cs @@ -0,0 +1,234 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyCompany.MyProject.WebApi.UsersAndPermissionsSystem.Data; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MyCompany.MyProject.WebApi.UsersAndPermissionsSystem.Migrations +{ + [DbContext(typeof(AppUsersAndPermissionsDbContext))] + partial class AppUsersAndPermissionsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("UsersAndPermissions") + .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AppPermissionAppRole", b => + { + b.Property("PermissionsId") + .HasColumnType("integer"); + + b.Property("RolesId") + .HasColumnType("integer"); + + b.HasKey("PermissionsId", "RolesId"); + + b.HasIndex("RolesId"); + + b.ToTable("AppPermissionAppRole", "UsersAndPermissions"); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppPermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AppPermissions", "UsersAndPermissions"); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("IsDefaultForAuthenticatedUser") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NormalizedName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AppRoles", "UsersAndPermissions"); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Email") + .HasColumnType("text"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasColumnType("text"); + + b.Property("NormalizedUserName") + .HasColumnType("text"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AppUser", "UsersAndPermissions"); + + b.HasDiscriminator().HasValue("AppUser"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("ReplacedByToken") + .HasColumnType("text"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", "UsersAndPermissions"); + }); + + modelBuilder.Entity("MyCompany.MyProject.WebApi.UsersAndPermissionsSystem.Entities.AppUser", b => + { + b.HasBaseType("Dappi.HeadlessCms.UsersAndPermissions.Core.AppUser"); + + b.HasDiscriminator().HasValue("AppUser"); + }); + + modelBuilder.Entity("AppPermissionAppRole", b => + { + b.HasOne("Dappi.HeadlessCms.UsersAndPermissions.Core.AppPermission", null) + .WithMany() + .HasForeignKey("PermissionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Dappi.HeadlessCms.UsersAndPermissions.Core.AppRole", null) + .WithMany() + .HasForeignKey("RolesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppUser", b => + { + b.HasOne("Dappi.HeadlessCms.UsersAndPermissions.Core.AppRole", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("Dappi.HeadlessCms.UsersAndPermissions.Core.AppRole", b => + { + b.Navigation("Users"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/templates/MyCompany.MyProject.sln b/templates/MyCompany.MyProject.sln index 73b59f19..ac56508d 100644 --- a/templates/MyCompany.MyProject.sln +++ b/templates/MyCompany.MyProject.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dappi.SourceGenerator", ".. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dappi.Core", "..\Dappi.Core\Dappi.Core.csproj", "{14795895-628E-4115-8317-B5017C6CA765}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dappi.HeadlessCms.UsersAndPermissions", "..\Dappi.HeadlessCms.UsersAndPermissions\Dappi.HeadlessCms.UsersAndPermissions.csproj", "{B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -69,6 +71,18 @@ Global {14795895-628E-4115-8317-B5017C6CA765}.Release|x64.Build.0 = Release|Any CPU {14795895-628E-4115-8317-B5017C6CA765}.Release|x86.ActiveCfg = Release|Any CPU {14795895-628E-4115-8317-B5017C6CA765}.Release|x86.Build.0 = Release|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Debug|x64.ActiveCfg = Debug|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Debug|x64.Build.0 = Debug|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Debug|x86.ActiveCfg = Debug|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Debug|x86.Build.0 = Debug|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Release|Any CPU.Build.0 = Release|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Release|x64.ActiveCfg = Release|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Release|x64.Build.0 = Release|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Release|x86.ActiveCfg = Release|Any CPU + {B9C824CE-6C29-4351-BF5E-9F3F24BB1CFE}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From fc751e7246c1888b53db8c2095ee21df3f63b607 Mon Sep 17 00:00:00 2001 From: Mihail Date: Wed, 1 Apr 2026 12:38:53 +0200 Subject: [PATCH 2/2] feat: add user delete option for "users and permissions" plugin, refactor: remove redundant --- .../users-and-permissions-plugin.service.ts | 6 ++ ...nd-permissions-plugin-users.component.html | 49 ++++++++---- ...nd-permissions-plugin-users.component.scss | 17 +++++ ...-and-permissions-plugin-users.component.ts | 76 ++++++++++++++++++- .../Api/UsersAndPermissionsController.cs | 29 ++++--- 5 files changed, 151 insertions(+), 26 deletions(-) diff --git a/CCUI.DAPPI/src/app/services/auth/users-and-permissions-plugin.service.ts b/CCUI.DAPPI/src/app/services/auth/users-and-permissions-plugin.service.ts index d128c961..3ddfc7ef 100644 --- a/CCUI.DAPPI/src/app/services/auth/users-and-permissions-plugin.service.ts +++ b/CCUI.DAPPI/src/app/services/auth/users-and-permissions-plugin.service.ts @@ -49,6 +49,12 @@ export class UsersAndPermissionsPluginService { ); } + deleteUser(userId: number): Observable<{ message: string }> { + return this.http.delete<{ message: string }>( + `${BASE_API_URL}${this.endpoint}/users/${userId}` + ); + } + getRolePermissions(roleName: string): Observable { const params = new HttpParams().set('roleName', roleName); diff --git a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.html b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.html index ef097e04..191b84b6 100644 --- a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.html +++ b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.html @@ -16,6 +16,20 @@

Users & Permissions plugin - Users

{{ inviteError }}
} +@if (selectedUserIds.size > 0) { +
+ +
+} + @if (usersAndPermissionsUsersLoading) {
@@ -31,6 +45,28 @@

There are no users yet

+ + + + + @@ -46,19 +82,6 @@

There are no users yet

- - - - -
+ + + + + + Username {{ user.userName }} {{ user.roleName || '-' }} Email confirmed - - @if (user.emailConfirmed) { - check_circle - } @else { - cancel - } - - Accepted Invitation diff --git a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.scss b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.scss index 8beca37e..92c26c61 100644 --- a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.scss +++ b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.scss @@ -51,6 +51,23 @@ font-size: 14px; } +.bulk-actions { + margin-bottom: 16px; +} + +.delete-selected-button { + background: transparent; + color: vars.$error-light; + justify-content: center; + align-items: center; + display: flex; + border: none; +} + +.small-cell { + width: 52px; +} + .content { flex: 1; display: flex; diff --git a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.ts b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.ts index da34a40d..1e8f0671 100644 --- a/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.ts +++ b/CCUI.DAPPI/src/app/settings/users-and-permissions-plugin-users/users-and-permissions-plugin-users.component.ts @@ -1,8 +1,9 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; +import { MatCheckboxChange, MatCheckboxModule } from '@angular/material/checkbox'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatTableModule } from '@angular/material/table'; -import { Subscription } from 'rxjs'; +import { forkJoin, Subscription } from 'rxjs'; import { UsersAndPermissionsPluginService, UsersAndPermissionsUserItem, @@ -14,7 +15,7 @@ import { InviteUserData, InviteUserDialogComponent } from '../../invite-user-dia @Component({ selector: 'app-users-and-permissions-users', standalone: true, - imports: [MatProgressSpinnerModule, MatTableModule, MatIconModule], + imports: [MatProgressSpinnerModule, MatTableModule, MatIconModule, MatCheckboxModule], templateUrl: './users-and-permissions-plugin-users.component.html', styleUrl: './users-and-permissions-plugin-users.component.scss', }) @@ -23,11 +24,13 @@ export class UsersAndPermissionsUsersComponent implements OnInit, OnDestroy { inviteButtonText = '+ Invite Users'; usersAndPermissionsUsers: UsersAndPermissionsUserItem[] = []; - usersAndPermissionsUserColumns: string[] = ['userName', 'email', 'roleName', 'emailConfirmed']; + usersAndPermissionsUserColumns: string[] = ['select', 'userName', 'email', 'roleName', 'acceptedInvitation']; usersAndPermissionsUsersLoading = false; usersAndPermissionsUsersError = ''; isEmailServiceAvailable = true; inviteError = ''; + deletingUsers = false; + selectedUserIds = new Set(); constructor( @@ -53,10 +56,12 @@ export class UsersAndPermissionsUsersComponent implements OnInit, OnDestroy { this.usersAndPermissionsPluginService.getAllUsers().subscribe({ next: (users) => { this.usersAndPermissionsUsers = users; + this.selectedUserIds.clear(); this.usersAndPermissionsUsersLoading = false; }, error: (error) => { this.usersAndPermissionsUsers = []; + this.selectedUserIds.clear(); this.usersAndPermissionsUsersError = this.getApiErrorMessage(error); this.usersAndPermissionsUsersLoading = false; }, @@ -91,6 +96,71 @@ export class UsersAndPermissionsUsersComponent implements OnInit, OnDestroy { }); } + get selectAllChecked(): boolean { + return this.usersAndPermissionsUsers.length > 0 + && this.selectedUserIds.size === this.usersAndPermissionsUsers.length; + } + + get selectAllIndeterminate(): boolean { + return this.selectedUserIds.size > 0 + && this.selectedUserIds.size < this.usersAndPermissionsUsers.length; + } + + toggleSelectAll(event: MatCheckboxChange): void { + this.selectedUserIds.clear(); + + if (event.checked) { + this.usersAndPermissionsUsers.forEach((user) => this.selectedUserIds.add(user.id)); + } + } + + toggleSelectUser(event: MatCheckboxChange, userId: number): void { + if (event.checked) { + this.selectedUserIds.add(userId); + return; + } + + this.selectedUserIds.delete(userId); + } + + isSelected(userId: number): boolean { + return this.selectedUserIds.has(userId); + } + + deleteSelectedUsers(): void { + if (!this.selectedUserIds.size || this.deletingUsers) { + return; + } + + const selectedCount = this.selectedUserIds.size; + const confirmationText = selectedCount === 1 + ? 'Are you sure you want to delete the selected user?' + : `Are you sure you want to delete ${selectedCount} selected users?`; + + if (!confirm(confirmationText)) { + return; + } + + this.deletingUsers = true; + this.usersAndPermissionsUsersError = ''; + + const deleteRequests = Array.from(this.selectedUserIds).map((userId) => this.usersAndPermissionsPluginService.deleteUser(userId) + ); + + this.subscription.add( + forkJoin(deleteRequests).subscribe({ + next: () => { + this.deletingUsers = false; + this.loadUsersAndPermissionsUsers(); + }, + error: (error) => { + this.deletingUsers = false; + this.usersAndPermissionsUsersError = this.getApiErrorMessage(error); + }, + }) + ); + } + private getApiErrorMessage(error: any): string { return ( error?.error?.message || diff --git a/Dappi.HeadlessCms.UsersAndPermissions/Api/UsersAndPermissionsController.cs b/Dappi.HeadlessCms.UsersAndPermissions/Api/UsersAndPermissionsController.cs index 65881a3a..9dabec69 100644 --- a/Dappi.HeadlessCms.UsersAndPermissions/Api/UsersAndPermissionsController.cs +++ b/Dappi.HeadlessCms.UsersAndPermissions/Api/UsersAndPermissionsController.cs @@ -127,17 +127,8 @@ public async Task InviteUser([FromBody] InviteUserDto dto) message = "Failed to prepare invitation.", }); } - - var emailService = service; - if (emailService is null) - { - return BadRequest(new - { - message = "Email service is not configured.", - }); - } - var messageId = await emailService.SendEmailAsync( + var messageId = await service!.SendEmailAsync( [dto.Email], invitation.EmailHtmlBody, invitation.EmailTextBody, @@ -409,4 +400,22 @@ public async Task GetAllUsers(CancellationToken cancellationToken return Ok(users); } + + [HttpDelete("users/{id:int}")] + public async Task DeleteUser(int id) + { + var user = await userManager.FindByIdAsync(id.ToString()); + if (user is null) + { + return NotFound(new { message = "User not found" }); + } + + var result = await userManager.DeleteAsync(user); + if (!result.Succeeded) + { + return BadRequest(result.Errors); + } + + return Ok(new { message = "User deleted successfully" }); + } }