From b31d76ece1ffb5ccc7a488d837bb36d463acbefe Mon Sep 17 00:00:00 2001 From: "Rahul R." Date: Mon, 6 Jan 2025 18:47:47 +0530 Subject: [PATCH] fix: #8703 so admin can't change own role --- .../edit-user-profile.component.ts | 72 ++++-- .../src/app/pages/users/users.component.html | 131 +++++++---- .../src/app/pages/users/users.component.ts | 215 ++++++++++++------ .../commands/employee.update.command.ts | 7 +- .../handlers/employee.update.handler.ts | 22 +- .../commands/handlers/user.delete.handler.ts | 18 +- .../lib/user/commands/user.delete.command.ts | 6 +- packages/core/src/lib/user/user.controller.ts | 19 +- packages/core/src/lib/user/user.service.ts | 85 +++++-- .../edit-profile-form.component.ts | 114 ++++++---- 10 files changed, 455 insertions(+), 234 deletions(-) diff --git a/apps/gauzy/src/app/pages/users/edit-user-profile/edit-user-profile.component.ts b/apps/gauzy/src/app/pages/users/edit-user-profile/edit-user-profile.component.ts index b206a3da127..706aa35ff6c 100644 --- a/apps/gauzy/src/app/pages/users/edit-user-profile/edit-user-profile.component.ts +++ b/apps/gauzy/src/app/pages/users/edit-user-profile/edit-user-profile.component.ts @@ -1,15 +1,13 @@ -import { Location } from '@angular/common'; import { Component, OnDestroy, OnInit } from '@angular/core'; -import { UntypedFormGroup } from '@angular/forms'; +import { FormGroup } from '@angular/forms'; import { ActivatedRoute, Params, Router } from '@angular/router'; -import { IUser, ITag, RolesEnum } from '@gauzy/contracts'; -import { firstValueFrom } from 'rxjs'; -import { filter, tap } from 'rxjs/operators'; +import { filter, firstValueFrom, tap } from 'rxjs'; +import { NbRouteTab } from '@nebular/theme'; import { TranslateService } from '@ngx-translate/core'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; +import { IUser, ITag, RolesEnum } from '@gauzy/contracts'; +import { AuthService, UsersService } from '@gauzy/ui-core/core'; import { TranslationBaseComponent } from '@gauzy/ui-core/i18n'; -import { UsersService } from '@gauzy/ui-core/core'; -import { AuthService } from '@gauzy/ui-core/core'; @UntilDestroy({ checkProperties: true }) @Component({ @@ -18,17 +16,15 @@ import { AuthService } from '@gauzy/ui-core/core'; styleUrls: ['./edit-user-profile.component.scss'] }) export class EditUserProfileComponent extends TranslationBaseComponent implements OnInit, OnDestroy { - form: UntypedFormGroup; + form: FormGroup; params: Params; user: IUser; - - tabs: any[]; + tabs: NbRouteTab[]; tags: ITag[]; constructor( private readonly route: ActivatedRoute, private readonly router: Router, - private readonly location: Location, public readonly translateService: TranslateService, private readonly usersService: UsersService, private readonly authService: AuthService @@ -41,7 +37,7 @@ export class EditUserProfileComponent extends TranslationBaseComponent implement .pipe( filter((params) => !!params), tap((params) => (this.params = params)), - tap(() => this.loadTabs()), + tap(() => this.registerPageTabs()), tap(() => this.getUserProfile()), untilDestroyed(this) ) @@ -52,15 +48,27 @@ export class EditUserProfileComponent extends TranslationBaseComponent implement this._applyTranslationOnTabs(); } - goBack() { - this.location.back(); - } + /** + * Generates the route for a given tab based on the current user ID. + * + * @param tab - The tab name to append to the route. + * @returns The full route string. + */ + getRoute(tab: string = ''): string { + if (!this.params?.id) { + return `/pages/users`; + } - getRoute(tab: string): string { - return `/pages/users/edit/${this.params.id}/${tab}`; + return `/pages/users/edit/${this.params?.id}/${tab}`; } - loadTabs() { + /** + * Registers page tabs for the dashboard module. + * Ensures that tabs are registered only once. + * + * @returns {void} + */ + registerPageTabs(): void { this.tabs = [ { title: this.getTranslation('USERS_PAGE.EDIT_USER.MAIN'), @@ -81,26 +89,40 @@ export class EditUserProfileComponent extends TranslationBaseComponent implement * GET user profile */ private async getUserProfile() { - const { id } = this.params; - const user = await this.usersService.getUserById(id, ['role', 'tags']); + if (!this.params.id) { + this.router.navigate(['/pages/users']); + return; + } - if (user.role.name === RolesEnum.SUPER_ADMIN) { + this.user = await this.usersService.getUserById(this.params.id, ['role', 'tags']); + + if (this.user?.role?.name === RolesEnum.SUPER_ADMIN) { /** * Redirect If Edit Super Admin Without Permission */ - const hasSuperAdminRole = await firstValueFrom(this.authService.hasRole([RolesEnum.SUPER_ADMIN])); + const hasSuperAdminRole = await firstValueFrom( + this.authService.hasRole([RolesEnum.SUPER_ADMIN]) + ); + if (!hasSuperAdminRole) { this.router.navigate(['/pages/users']); return; } } - this.user = user; } - private _applyTranslationOnTabs() { + /** + * Subscribes to language change events and applies translations to page tabs. + * Ensures the tabs are updated dynamically when the language changes. + * Uses `untilDestroyed` to clean up subscriptions when the component is destroyed. + */ + private _applyTranslationOnTabs(): void { this.translateService.onLangChange .pipe( - tap(() => this.loadTabs()), + // Re-register page tabs on language change + tap(() => this.registerPageTabs()), + + // Automatically unsubscribe when the component is destroyed untilDestroyed(this) ) .subscribe(); diff --git a/apps/gauzy/src/app/pages/users/users.component.html b/apps/gauzy/src/app/pages/users/users.component.html index 2ef56289fc6..23203f31d62 100644 --- a/apps/gauzy/src/app/pages/users/users.component.html +++ b/apps/gauzy/src/app/pages/users/users.component.html @@ -1,4 +1,8 @@ - +

@@ -9,10 +13,10 @@

+ + - - diff --git a/apps/gauzy/src/app/pages/users/users.component.ts b/apps/gauzy/src/app/pages/users/users.component.ts index aab5f3aef47..fc620bb9766 100644 --- a/apps/gauzy/src/app/pages/users/users.component.ts +++ b/apps/gauzy/src/app/pages/users/users.component.ts @@ -6,14 +6,6 @@ import { Cell, LocalDataSource } from 'angular2-smart-table'; import { filter, tap } from 'rxjs/operators'; import { debounceTime, firstValueFrom, Subject } from 'rxjs'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { - EmployeesService, - ErrorHandlingService, - Store, - ToastrService, - UsersOrganizationsService, - monthNames -} from '@gauzy/ui-core/core'; import { InvitationTypeEnum, PermissionsEnum, @@ -28,6 +20,13 @@ import { ITag, IEmployee } from '@gauzy/contracts'; +import { + EmployeesService, + ErrorHandlingService, + Store, + ToastrService, + UsersOrganizationsService +} from '@gauzy/ui-core/core'; import { ComponentEnum, distinctUntilChange } from '@gauzy/ui-core/common'; import { DateFormatPipe, @@ -50,24 +49,21 @@ import { EmployeeWorkStatusComponent } from '../employees/table-components'; styleUrls: ['./users.component.scss'] }) export class UsersComponent extends PaginationFilterBaseComponent implements OnInit, OnDestroy { - settingsSmartTable: object; - sourceSmartTable = new LocalDataSource(); - selectedUser: IUserViewModel; - - userName = 'User'; - - loading: boolean; - hasSuperAdminPermission: boolean = false; - organizationInvitesAllowed: boolean = false; - showAddCard: boolean; - disableButton = true; - - viewComponentName: ComponentEnum; - dataLayoutStyle = ComponentLayoutStyleEnum.TABLE; - componentLayoutStyleEnum = ComponentLayoutStyleEnum; - - users: IUser[] = []; - organization: IOrganization; + public PermissionsEnum = PermissionsEnum; + public users: IUser[] = []; + public settingsSmartTable: object; + public sourceSmartTable = new LocalDataSource(); + public selectedUser: IUserViewModel; + public userName = 'User'; + public loading: boolean; + public hasSuperAdminPermission: boolean = false; + public organizationInvitesAllowed: boolean = false; + public showAddCard: boolean; + public disableButton = true; + public viewComponentName: ComponentEnum; + public dataLayoutStyle = ComponentLayoutStyleEnum.TABLE; + public componentLayoutStyleEnum = ComponentLayoutStyleEnum; + public organization: IOrganization; private _refresh$: Subject = new Subject(); constructor( @@ -94,7 +90,6 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI debounceTime(300), tap(() => this.getUsers()), tap(() => this.cancel()), - tap(() => this.clearItem()), untilDestroyed(this) ) .subscribe(); @@ -143,94 +138,182 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI .subscribe(); } - setView() { + /** + * Sets the view to the 'Users' component and updates the layout style. + * Subscribes to the layout changes and triggers necessary updates like pagination and data clearing. + */ + setView(): void { + // Set the component view name this.viewComponentName = ComponentEnum.USERS; + + // Listen for layout changes related to the 'Users' component this.store .componentLayout$(this.viewComponentName) .pipe( + // Avoid emitting if the layout style has not changed distinctUntilChange(), + + // Update the data layout style based on the emitted layout tap((componentLayout: ComponentLayoutStyleEnum) => (this.dataLayoutStyle = componentLayout)), + + // Refresh pagination settings whenever the layout changes tap(() => this.refreshPagination()), + + // Only proceed further if the current layout is grid-based filter(() => this._isGridLayout), + + // Clear the users array when switching to grid layout tap(() => (this.users = [])), + + // Trigger a refresh event for components relying on the subject$ tap(() => this.subject$.next(true)), + + // Automatically clean up the subscription when the component is destroyed untilDestroyed(this) ) .subscribe(); } + /** + * Determines if the current layout style is set to grid (cards grid). + * This getter provides a clean and concise way to check the layout type. + * + * @returns A boolean indicating whether the layout style is 'CARDS_GRID'. + */ private get _isGridLayout(): boolean { return this.componentLayoutStyleEnum.CARDS_GRID === this.dataLayoutStyle; } - selectUser({ isSelected, data }) { + /** + * Handles the selection of a user from the list. + * Updates the selected user, enables/disables the button, + * and checks specific conditions like user role and permissions. + * + * @param param0 - The selection event containing user data. + * @param param0.isSelected - Indicates if the user is selected. + * @param param0.data - The data object of the selected user. + */ + selectUser({ isSelected, data }: { isSelected: boolean; data: any }): void { + // Toggle the button state and update the selected user this.disableButton = !isSelected; this.selectedUser = isSelected ? data : null; - if (this.selectedUser) { - const checkName = data.fullName.trim(); - this.userName = checkName ? checkName : 'User'; - } + // Set the user's display name or default to 'User' + this.userName = data?.fullName?.trim() || 'User'; - if (data && data.role === RolesEnum.SUPER_ADMIN) { - this.disableButton = !this.hasSuperAdminPermission; + // Handle SUPER_ADMIN role-specific logic + if (data?.role?.name === RolesEnum.SUPER_ADMIN) { + this.disableButton = this.hasSuperAdminPermission; this.selectedUser = this.hasSuperAdminPermission ? this.selectedUser : null; } } - async add() { + /** + * Opens a dialog to add a new user and handles the response. + * If a user is added successfully, displays a success message and triggers updates for related components. + */ + async add(): Promise { + // Open the user mutation dialog const dialog = this.dialogService.open(UserMutationComponent); + + // Wait for the dialog to close and retrieve the data const data = await firstValueFrom(dialog.onClose); + // Check if data exists and contains a user if (data && data.user) { + // Construct the user's full name if first or last name is provided if (data.user.firstName || data.user.lastName) { - this.userName = data.user.firstName + ' ' + data.user.lastName; + this.userName = `${data.user.firstName || ''} ${data.user.lastName || ''}`.trim(); } + + // Display a success message using ToastrService this.toastrService.success('NOTES.ORGANIZATIONS.ADD_NEW_USER_TO_ORGANIZATION', { - username: this.userName.trim(), + username: this.userName, orgname: this.store.selectedOrganization.name }); + + // Notify subscribers about the update this._refresh$.next(true); this.subject$.next(true); } } - async addOrEditUser(user: IUserOrganizationCreateInput) { + + /** + * Adds or edits a user in the organization based on the input data. + * If the user is active, creates the user and triggers updates for related components. + * + * @param user - The user data to be added or edited. + */ + async addOrEditUser(user: IUserOrganizationCreateInput): Promise { + // Check if the user is active if (user.isActive) { + // Create the user in the organization and wait for the operation to complete await firstValueFrom(this.userOrganizationsService.create(user)); + // Show a success message using ToastrService this.toastrService.success('NOTES.ORGANIZATIONS.ADD_NEW_USER_TO_ORGANIZATION', { username: this.userName.trim(), orgname: this.store.selectedOrganization.name }); + + // Trigger updates for other components or subscribers this._refresh$.next(true); this.subject$.next(true); } } - async invite() { + /** + * Opens a dialog for inviting a user. + * Uses the `InviteMutationComponent` with the context set to user invitation. + * Waits for the dialog to close before proceeding. + */ + async invite(): Promise { + // Open the invite dialog with the specified context const dialog = this.dialogService.open(InviteMutationComponent, { context: { invitationType: InvitationTypeEnum.USER } }); + + // Wait for the dialog to close and handle any resulting actions (if needed) await firstValueFrom(dialog.onClose); } - edit(selectedItem?: IUser) { + /** + * Navigates to the edit page for a selected user. + * If a user is passed as a parameter, it selects that user before navigation. + * + * @param selectedItem - The user object to edit (optional). + */ + edit(selectedItem?: IUser): void { + // If a user is provided, select that user if (selectedItem) { this.selectUser({ isSelected: true, data: selectedItem }); } - this.router.navigate(['/pages/users/edit/' + this.selectedUser.id]); + + // Navigate to the edit page of the selected user + if (this.selectedUser?.id) { + this.router.navigate(['/pages/users/edit/' + this.selectedUser.id]); + } } - manageInvites() { + /** + * Navigates to the user invites management page. + */ + manageInvites(): void { this.router.navigate(['/pages/users/invites/']); } + /** + * Opens a dialog for deleting a user. + * If a user is passed as a parameter, it selects that user before navigation. + * + * @param selectedItem + */ async delete(selectedItem?: IUser) { if (selectedItem) { this.selectUser({ @@ -248,10 +331,11 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI .subscribe(async (result) => { if (result) { try { + const username = this.userName; + await this.userOrganizationsService.setUserAsInactive(this.selectedUser.id); - this.toastrService.success('NOTES.ORGANIZATIONS.DELETE_USER_FROM_ORGANIZATION', { - username: this.userName - }); + this.toastrService.success('NOTES.ORGANIZATIONS.DELETE_USER_FROM_ORGANIZATION', { username }); + this._refresh$.next(true); this.subject$.next(true); } catch (error) { @@ -261,7 +345,11 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI }); } - cancel() { + /** + * Cancels the current operation and hides the add card form. + * Resets the `showAddCard` flag to `false`. + */ + cancel(): void { this.showAddCard = false; } @@ -279,8 +367,7 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI * User belongs multiple organizations -> remove user from Organization */ const count = await this.userOrganizationsService.getUserOrganizationCount(userOrganizationId); - const confirmationMessage = - count === 1 ? 'FORM.DELETE_CONFIRMATION.DELETE_USER' : 'FORM.DELETE_CONFIRMATION.REMOVE_USER'; + const confirmationMessage = count === 1 ? 'FORM.DELETE_CONFIRMATION.DELETE_USER' : 'FORM.DELETE_CONFIRMATION.REMOVE_USER'; // Open a confirmation dialog for the hiring action. const dialogRef = this.dialogService.open(DeleteConfirmationComponent, { @@ -425,6 +512,9 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI this.users.push(...uniqueUsers); } + /** + * + */ private _loadSmartTableSettings() { const pagination: IPaginationBase = this.getPagination(); this.settingsSmartTable = { @@ -530,10 +620,9 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI if (!employee) { return {}; } + const { endWork, startedWorkOn, isTrackingEnabled, id } = employee; - /** - * "Range" when was hired and when exit - */ + // "Range" when was hired and when exit const start = this._dateFormatPipe.transform(startedWorkOn, null, 'LL'); const end = this._dateFormatPipe.transform(endWork, null, 'LL'); @@ -549,19 +638,6 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI }; } - /** - * Formats a date in the format "DD Month YYYY". - * - * @param date The date object to be formatted. - * @returns A string representing the formatted date. - */ - private formatDate(date: Date): string { - const day = date.getDate(); - const month = monthNames[date.getMonth()]; - const year = date.getFullYear(); - return `${day} ${month} ${year}`; - } - /** * Checks if the user is an employee. * @@ -592,8 +668,8 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI return; } - const { id: organizationId } = this.organization; - const { id: userId, tenantId } = this.selectedUser; + const { id: organizationId, tenantId } = this.organization; + const { id: userId } = this.selectedUser; try { await firstValueFrom( @@ -611,5 +687,10 @@ export class UsersComponent extends PaginationFilterBaseComponent implements OnI } } + // Method to toggle the 'showAddCard' state + toggleAddCard(): void { + this.showAddCard = !this.showAddCard; + } + ngOnDestroy() {} } diff --git a/packages/core/src/lib/employee/commands/employee.update.command.ts b/packages/core/src/lib/employee/commands/employee.update.command.ts index 45c9531a5ca..b93da7cded7 100644 --- a/packages/core/src/lib/employee/commands/employee.update.command.ts +++ b/packages/core/src/lib/employee/commands/employee.update.command.ts @@ -1,11 +1,8 @@ import { ICommand } from '@nestjs/cqrs'; -import { IEmployee, IEmployeeUpdateInput } from '@gauzy/contracts'; +import { ID, IEmployeeUpdateInput } from '@gauzy/contracts'; export class EmployeeUpdateCommand implements ICommand { static readonly type = '[Employee] Update'; - constructor( - public readonly id: IEmployee['id'], - public readonly input: IEmployeeUpdateInput, - ) { } + constructor(public readonly id: ID, public readonly input: IEmployeeUpdateInput) { } } diff --git a/packages/core/src/lib/employee/commands/handlers/employee.update.handler.ts b/packages/core/src/lib/employee/commands/handlers/employee.update.handler.ts index e43379d0624..e2cafb714fd 100644 --- a/packages/core/src/lib/employee/commands/handlers/employee.update.handler.ts +++ b/packages/core/src/lib/employee/commands/handlers/employee.update.handler.ts @@ -1,6 +1,6 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { IEmployee, PermissionsEnum } from '@gauzy/contracts'; import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; -import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { EmployeeUpdateCommand } from './../employee.update.command'; import { EmployeeService } from './../../employee.service'; import { RequestContext } from './../../../core/context'; @@ -9,8 +9,19 @@ import { RequestContext } from './../../../core/context'; export class EmployeeUpdateHandler implements ICommandHandler { constructor(private readonly _employeeService: EmployeeService) {} + /** + * Handles the execution of the `EmployeeUpdateCommand`. + * Ensures proper permissions are enforced and updates the employee's profile. + * + * @param command - The `EmployeeUpdateCommand` containing the employee ID and input data. + * @returns The updated employee entity. + * @throws ForbiddenException if the user lacks permissions or tries to edit another employee's profile. + * @throws BadRequestException if the update operation fails. + */ public async execute(command: EmployeeUpdateCommand): Promise { const { id, input } = command; + const user = RequestContext.currentUser(); + /** * If user/employee has only own profile edit permission */ @@ -18,15 +29,13 @@ export class EmployeeUpdateHandler implements ICommandHandler { - constructor( - private readonly userService: UserService - ) { } + constructor(private readonly userService: UserService) { } + /** + * Executes the `UserDeleteCommand` to delete a user by ID. + * + * @param command - The `UserDeleteCommand` containing the ID of the user to delete. + * @returns A promise resolving to the `DeleteResult` of the operation. + * @throws ForbiddenException if the deletion fails. + */ public async execute(command: UserDeleteCommand): Promise { + const { userId } = command; + try { - let { userId } = command; + // Attempt to delete the user by ID return await this.userService.delete(userId); } catch (error) { - throw new ForbiddenException(); + // Handle errors and throw a ForbiddenException for unauthorized operations + throw new ForbiddenException('You are not allowed to delete this user.'); } } } diff --git a/packages/core/src/lib/user/commands/user.delete.command.ts b/packages/core/src/lib/user/commands/user.delete.command.ts index 5bc77fe779f..98643bff332 100644 --- a/packages/core/src/lib/user/commands/user.delete.command.ts +++ b/packages/core/src/lib/user/commands/user.delete.command.ts @@ -1,10 +1,8 @@ import { ICommand } from '@nestjs/cqrs'; -import { IUser } from '@gauzy/contracts'; +import { ID } from '@gauzy/contracts'; export class UserDeleteCommand implements ICommand { static readonly type = '[User] Delete Account'; - constructor( - public readonly userId: IUser['id'] - ) { } + constructor(public readonly userId: ID) { } } diff --git a/packages/core/src/lib/user/user.controller.ts b/packages/core/src/lib/user/user.controller.ts index 9fae3910349..a1429ced17f 100644 --- a/packages/core/src/lib/user/user.controller.ts +++ b/packages/core/src/lib/user/user.controller.ts @@ -120,7 +120,7 @@ export class UserController extends CrudController { */ @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ORG_USERS_VIEW) - @Get('count') + @Get('/count') async getCount(@Query() options: FindOptionsWhere): Promise { return await this._userService.countBy(options); } @@ -133,7 +133,7 @@ export class UserController extends CrudController { */ @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ORG_USERS_VIEW) - @Get('pagination') + @Get('/pagination') async pagination(@Query() options: PaginationParams): Promise> { return await this._userService.paginate(options); } @@ -156,7 +156,7 @@ export class UserController extends CrudController { }) @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ORG_USERS_VIEW) - @Get() + @Get('/') async findAll(@Query() options: PaginationParams): Promise> { return await this._userService.findAll(options); } @@ -178,7 +178,7 @@ export class UserController extends CrudController { status: HttpStatus.NOT_FOUND, description: 'Record not found' }) - @Get(':id') + @Get('/:id') async findById(@Param('id', UUIDValidationPipe) id: ID, @Query('data', ParseJsonPipe) data?: any): Promise { const { relations } = data; return await this._userService.findOneByIdString(id, { relations }); @@ -202,7 +202,7 @@ export class UserController extends CrudController { @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ORG_USERS_EDIT) @HttpCode(HttpStatus.CREATED) - @Post() + @Post('/') @UseValidationPipe() async create(@Body() entity: CreateUserDTO): Promise { return await this._commandBus.execute(new UserCreateCommand(entity)); @@ -218,9 +218,12 @@ export class UserController extends CrudController { @HttpCode(HttpStatus.ACCEPTED) @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ORG_USERS_EDIT, PermissionsEnum.PROFILE_EDIT) - @Put(':id') + @Put('/:id') @UseValidationPipe({ transform: true }) - async update(@Param('id', UUIDValidationPipe) id: ID, @Body() entity: UpdateUserDTO): Promise { + async update( + @Param('id', UUIDValidationPipe) id: ID, + @Body() entity: UpdateUserDTO + ): Promise { return await this._userService.updateProfile(id, { id, ...entity @@ -246,7 +249,7 @@ export class UserController extends CrudController { }) @UseGuards(TenantPermissionGuard, PermissionGuard) @Permissions(PermissionsEnum.ALL_ORG_EDIT, PermissionsEnum.ACCESS_DELETE_ACCOUNT) - @Delete(':id') + @Delete('/:id') async delete(@Param('id', UUIDValidationPipe) id: ID): Promise { return await this._commandBus.execute(new UserDeleteCommand(id)); } diff --git a/packages/core/src/lib/user/user.service.ts b/packages/core/src/lib/user/user.service.ts index d822b3c4e21..92cbe9b4be4 100644 --- a/packages/core/src/lib/user/user.service.ts +++ b/packages/core/src/lib/user/user.service.ts @@ -274,59 +274,98 @@ export class UserService extends TenantAwareCrudService { return await this.typeOrmRepository.insert(user); } - async changePassword(id: string, hash: string) { + /** + * Updates the password for a user. + * + * @param id - The ID of the user whose password is to be changed. + * @param hash - The new hashed password to set for the user. + * @returns A promise resolving to the updated user entity. + * @throws ForbiddenException if the operation fails. + */ + async changePassword(id: ID, hash: string): Promise { try { + // Fetch the user by ID const user = await this.findOneByIdString(id); + + // Update the user's password hash user.hash = hash; + + // Save the updated user entity return await this.typeOrmRepository.save(user); } catch (error) { - throw new ForbiddenException(); + // Throw a ForbiddenException if any error occurs + throw new ForbiddenException('Failed to update the password.'); } } - /* - * Update user profile + /** + * Updates the profile of a user. + * Ensures the user has the necessary permissions and applies restrictions to role updates. + * + * @param id - The ID of the user to update. + * @param entity - The user entity with updated data. + * @returns The updated user entity. + * @throws ForbiddenException if the user lacks the required permissions or attempts unauthorized updates. */ - async updateProfile(id: string | number, entity: User): Promise { - /** - * If user has only own profile edit permission - */ + async updateProfile(id: ID | number, entity: User): Promise { + // Retrieve the current user's role ID from the RequestContext + const currentRoleId = RequestContext.currentRoleId(); + const currentUserId = RequestContext.currentUserId(); + + // Ensure the user has the appropriate permissions if ( RequestContext.hasPermission(PermissionsEnum.PROFILE_EDIT) && !RequestContext.hasPermission(PermissionsEnum.ORG_USERS_EDIT) ) { - if (RequestContext.currentUserId() !== id) { + // Users can only edit their own profile + if (currentUserId !== id) { throw new ForbiddenException(); } } + let user: IUser; + try { + // Fetch the user by ID if the ID is a string if (typeof id == 'string') { - user = await this.findOneByIdString(id, { - relations: { - role: true - } - }); + user = await this.findOneByIdString(id, { relations: { role: true } }); } - /** - * If user try to update Super Admin without permission - */ + + // Restrict updates to Super Admin role without appropriate permission if (user.role.name === RolesEnum.SUPER_ADMIN) { if (!RequestContext.hasPermission(PermissionsEnum.SUPER_ADMIN_EDIT)) { throw new ForbiddenException(); } } + + // Restrict updates to Super Admin role without appropriate permission + if (user.role.name === RolesEnum.SUPER_ADMIN) { + if (!RequestContext.hasPermission(PermissionsEnum.SUPER_ADMIN_EDIT)) { + throw new ForbiddenException(); + } + } + + // Restrict users from updating their own role + + if (currentUserId === id) { + if (entity.role && entity.role.id !== currentRoleId) { + throw new ForbiddenException(); + } + } + + // Update password hash if provided if (entity['hash']) { entity['hash'] = await this.getPasswordHash(entity['hash']); } + // Save the updated user entity await this.save(entity); - try { - return await this.findOneByWhereOptions({ - id: id as string, - tenantId: RequestContext.currentTenantId() - }); - } catch {} + + // Return the updated user + return await this.findOneByWhereOptions({ + id: id as string, + tenantId: RequestContext.currentTenantId() + }); } catch (error) { throw new ForbiddenException(); } diff --git a/packages/ui-core/shared/src/lib/user/edit-profile-form/edit-profile-form.component.ts b/packages/ui-core/shared/src/lib/user/edit-profile-form/edit-profile-form.component.ts index 47766c173b3..51d1b5c0034 100644 --- a/packages/ui-core/shared/src/lib/user/edit-profile-form/edit-profile-form.component.ts +++ b/packages/ui-core/shared/src/lib/user/edit-profile-form/edit-profile-form.component.ts @@ -2,14 +2,14 @@ import { Component, OnInit, OnDestroy, Input, Output, EventEmitter } from '@angu import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { IUser, ITag, IRole, IUserUpdateInput, RolesEnum, IImageAsset, DEFAULT_TIME_FORMATS } from '@gauzy/contracts'; import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy'; -import { Subject, firstValueFrom } from 'rxjs'; -import { debounceTime, filter, tap } from 'rxjs/operators'; -import { EmailValidator, Store } from '@gauzy/ui-core/core'; +import { Subject, filter, debounceTime, tap, firstValueFrom } from 'rxjs'; import { AuthService, + EmailValidator, ErrorHandlingService, MatchValidator, RoleService, + Store, ToastrService, UsersService } from '@gauzy/ui-core/core'; @@ -24,13 +24,11 @@ import { patterns } from '../../regex'; }) export class EditProfileFormComponent implements OnInit, OnDestroy { FormHelpers: typeof FormHelpers = FormHelpers; - hoverState: boolean; loading: boolean; listOfTimeFormats = DEFAULT_TIME_FORMATS; role: IRole; user: IUser; - user$: Subject = new Subject(); /* @@ -57,7 +55,7 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { @Output() userSubmitted = new EventEmitter(); - public form: UntypedFormGroup = EditProfileFormComponent.buildForm(this.fb); + public form: UntypedFormGroup = EditProfileFormComponent.buildForm(this._fb); static buildForm(fb: UntypedFormBuilder): UntypedFormGroup { return fb.group( { @@ -84,13 +82,13 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { public excludes: RolesEnum[] = []; constructor( - private readonly fb: UntypedFormBuilder, - private readonly authService: AuthService, - private readonly userService: UsersService, - private readonly store: Store, - private readonly toastrService: ToastrService, - private readonly errorHandler: ErrorHandlingService, - private readonly roleService: RoleService + private readonly _fb: UntypedFormBuilder, + private readonly _authService: AuthService, + private readonly _userService: UsersService, + private readonly _store: Store, + private readonly _toastrService: ToastrService, + private readonly _errorHandler: ErrorHandlingService, + private readonly _roleService: RoleService ) {} async ngOnInit() { @@ -102,7 +100,7 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { untilDestroyed(this) ) .subscribe(); - this.store.user$ + this._store.user$ .pipe( filter((user: IUser) => !!user), tap((user: IUser) => (this.user = user)), @@ -112,31 +110,55 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { .subscribe(); } - async excludeRoles() { - const hasSuperAdminRole = await firstValueFrom(this.authService.hasRole([RolesEnum.SUPER_ADMIN])); - if (!hasSuperAdminRole) { - this.excludes.push(RolesEnum.SUPER_ADMIN); + /** + * Excludes roles based on the user's permissions. + * Adds the SUPER_ADMIN role to the excludes list if the user lacks SUPER_ADMIN privileges. + */ + async excludeRoles(): Promise { + try { + // Check if the user has the SUPER_ADMIN role + const hasSuperAdminRole = await firstValueFrom( + this._authService.hasRole([RolesEnum.SUPER_ADMIN]) + ); + + // Add SUPER_ADMIN to the excludes list if the user lacks the role + if (!hasSuperAdminRole) { + this.excludes.push(RolesEnum.SUPER_ADMIN); + } + } catch (error) { + this._errorHandler?.handleError(error); // Optional error handling if applicable } } - async getUserProfile() { + /** + * Retrieves the profile of the selected user or the current user. + * Fetches user details including tags and role, and updates the form. + */ + async getUserProfile(): Promise { try { - const { id: userId } = this.selectedUser || this.user; - const user = await this.userService.getUserById(userId, ['tags', 'role']); + // Get the user ID from the selected user or fallback to the current user + const userId = this.selectedUser?.id || this.user?.id; + if (!userId) { + throw new Error('User ID is missing.'); + } + // Fetch user details with specific relations + const user = await this._userService.getUserById(userId, ['tags', 'role']); + + // Patch the form with the retrieved user data this._patchForm({ ...user }); } catch (error) { - this.errorHandler.handleError(error); + this._errorHandler?.handleError(error); // Handle errors gracefully } } handleImageUploadError(error: any) { - this.toastrService.danger(error); + this._toastrService.danger(error); } async updateImageAsset(image: IImageAsset) { - this.store.user = { - ...this.store.user, + this._store.user = { + ...this._store.user, imageId: image.id }; @@ -145,9 +167,9 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { }; if (this.allowRoleChange) { - const { tenantId } = this.store.user; + const { tenantId } = this._store.user; const role = await firstValueFrom( - this.roleService.getRoleByOptions({ + this._roleService.getRoleByOptions({ name: this.form.get('role').value.name, tenantId }) @@ -160,23 +182,23 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { } try { - await this.userService - .update(this.selectedUser ? this.selectedUser.id : this.store.userId, request) + await this._userService + .update(this.selectedUser ? this.selectedUser.id : this._store.userId, request) .then((res: IUser) => { try { if (res) { - this.store.user = { - ...this.store.user, + this._store.user = { + ...this._store.user, imageUrl: res.imageUrl } as IUser; } - this.toastrService.success('TOASTR.MESSAGE.IMAGE_UPDATED'); + this._toastrService.success('TOASTR.MESSAGE.IMAGE_UPDATED'); } catch (error) { console.log('Error while uploading profile avatar', error); } }); } catch (error) { - this.errorHandler.handleError(error); + this._errorHandler.handleError(error); } } @@ -185,7 +207,7 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { const { email, firstName, lastName, tags, preferredLanguage, password, phoneNumber } = this.form.value; if (!EmailValidator.isValid(email, patterns.email)) { - this.toastrService.error('TOASTR.MESSAGE.EMAIL_SHOULD_BE_REAL'); + this._toastrService.error('TOASTR.MESSAGE.EMAIL_SHOULD_BE_REAL'); return; } let request: IUserUpdateInput = { @@ -207,9 +229,9 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { } if (this.allowRoleChange) { - const { tenantId } = this.store.user; + const { tenantId } = this._store.user; const role = await firstValueFrom( - this.roleService.getRoleByOptions({ + this._roleService.getRoleByOptions({ name: this.form.get('role').value.name, tenantId }) @@ -222,26 +244,26 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { } try { - await this.userService - .update(this.selectedUser ? this.selectedUser.id : this.store.userId, request) + await this._userService + .update(this.selectedUser ? this.selectedUser.id : this._store.userId, request) .then(() => { - if ((this.selectedUser ? this.selectedUser.id : this.store.userId) === this.store.user.id) { - this.store.user.email = request.email; + if ((this.selectedUser ? this.selectedUser.id : this._store.userId) === this._store.user.id) { + this._store.user.email = request.email; } - this.toastrService.success('TOASTR.MESSAGE.PROFILE_UPDATED'); + this._toastrService.success('TOASTR.MESSAGE.PROFILE_UPDATED'); this.userSubmitted.emit(); /** * selectedUser is null for edit profile and populated in User edit * Update app language when current user's profile is modified. */ - if (this.selectedUser && this.selectedUser.id !== this.store.userId) { + if (this.selectedUser && this.selectedUser.id !== this._store.userId) { return; } - this.store.preferredLanguage = preferredLanguage; + this._store.preferredLanguage = preferredLanguage; }); } catch (error) { - this.errorHandler.handleError(error); + this._errorHandler.handleError(error); } } @@ -266,6 +288,10 @@ export class EditProfileFormComponent implements OnInit, OnDestroy { this.role = user.role; } + /** + * + * @param tags + */ selectedTagsHandler(tags: ITag[]) { this.form.get('tags').setValue(tags); this.form.get('tags').updateValueAndValidity();