diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index f2e552a..c0b5fb9 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -44,6 +44,26 @@ export class InviteUserDto { orgRoleId?: string; } +export class UpdateOrgUserDto { + @IsString() + @MinLength(2) + @MaxLength(120) + fullName!: string; + + @IsString() + @Matches(/^09\d{9}$|^\+98\d{10}$|^98\d{10}$|^9\d{9}$/, { + message: 'شماره موبایل معتبر نیست', + }) + mobile!: string; + + @IsOptional() + @ValidateIf((_, value) => typeof value === 'string' && value.length > 0) + @IsString() + @MinLength(8) + @MaxLength(72) + password?: string; +} + export class UpdateUserRoleDto { @IsOptional() @IsIn(ALL_ROLES) diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 00beada..a254215 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -15,6 +15,7 @@ import { Roles } from '../auth/roles.decorator'; import { RolesGuard } from '../auth/roles.guard'; import { InviteUserDto, + UpdateOrgUserDto, UpdateUserCommissionDto, UpdateUserRoleDto, UpdateUserStatusDto, @@ -50,6 +51,21 @@ export class UsersController { return this.usersService.invite(user.organizationId, user.role, dto); } + @Patch(':id') + @Roles('owner', 'admin') + updateProfile( + @CurrentUser() user: { organizationId: string; role: UserRole }, + @Param('id') id: string, + @Body() dto: UpdateOrgUserDto, + ) { + return this.usersService.updateProfile( + user.organizationId, + user.role, + id, + dto, + ); + } + @Patch(':id/commission') @Roles('owner', 'admin') updateCommission( diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index f993fab..be18027 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -9,6 +9,7 @@ import type { DataVisibility, InviteUserRequest, OrgUserDto, + UpdateOrgUserRequest, UserRole, } from '@mento/shared'; import { DATA_VISIBILITIES } from '@mento/shared'; @@ -136,6 +137,80 @@ export class UsersService { } } + async updateProfile( + organizationId: string, + actorRole: UserRole, + targetId: string, + dto: UpdateOrgUserRequest, + ): Promise { + if (actorRole !== 'owner' && actorRole !== 'admin') { + throw new ForbiddenException('فقط مالک یا مدیر می‌تواند مشخصات کاربر را ویرایش کند'); + } + + const target = await this.memberships.findUserInOrgById( + organizationId, + targetId, + ); + if (target.role === 'owner' && actorRole !== 'owner') { + throw new ForbiddenException('مدیر نمی‌تواند مشخصات مالک را تغییر دهد'); + } + + const fullName = dto.fullName.trim(); + if (fullName.length < 2) { + throw new BadRequestException('نام باید حداقل دو حرف باشد'); + } + const mobile = normalizeMobile(dto.mobile); + if (!mobile) { + throw new BadRequestException('شماره موبایل معتبر نیست'); + } + + const previousMobile = target.user.mobile ?? ''; + if (mobile !== previousMobile) { + const taken = await this.prisma.user.findFirst({ + where: { mobile, id: { not: targetId } }, + select: { id: true }, + }); + if (taken) { + throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); + } + } + + const data: Prisma.UserUpdateInput = { fullName, mobile }; + const prevEmail = target.user.email; + const wasSynthetic = + prevEmail.endsWith('@users.mento.local') || + prevEmail === inviteEmailFromMobile(previousMobile); + if (wasSynthetic && mobile !== previousMobile) { + const nextEmail = inviteEmailFromMobile(mobile); + const emailTaken = await this.prisma.user.findFirst({ + where: { email: nextEmail, id: { not: targetId } }, + select: { id: true }, + }); + if (!emailTaken) data.email = nextEmail; + } + + const password = dto.password?.trim() ?? ''; + if (password) { + if (password.length < 8) { + throw new BadRequestException('رمز عبور باید حداقل ۸ کاراکتر باشد'); + } + data.passwordHash = await bcrypt.hash(password, 10); + } + + try { + await this.prisma.user.update({ + where: { id: targetId }, + data, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new ConflictException('این شماره موبایل یا ایمیل قبلاً ثبت شده است'); + } + throw e; + } + return this.dtoOf(organizationId, targetId); + } + async updateCommission( organizationId: string, actorRole: UserRole, diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index 7537a39..af9243f 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -14,6 +14,7 @@ import { type OrgUserDto, type RolePermissionKey, type UpdateOrganizationRequest, + type UpdateOrgUserRequest, } from '@mento/shared'; import { canManageSettings, canManageUsers } from '@/lib/roles'; import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields'; @@ -71,6 +72,14 @@ const inviteMobile = ref(''); const invitePassword = ref(''); const inviteRoleId = ref(''); +const editOpen = ref(false); +const editSaving = ref(false); +const editUserId = ref(null); +const editName = ref(''); +const editMobile = ref(''); +const editPassword = ref(''); +const editRoleLabel = ref(''); + const roleFormOpen = ref(false); const roleFormSaving = ref(false); const roleFormId = ref(null); @@ -366,6 +375,47 @@ async function submitInvite() { } } +function openEditUser(user: OrgUserDto) { + menuUserId.value = null; + editUserId.value = user.id; + editName.value = user.fullName; + editMobile.value = user.mobile ?? ''; + editPassword.value = ''; + editRoleLabel.value = user.orgRoleName || '—'; + editOpen.value = true; + error.value = ''; + ok.value = ''; +} + +async function submitEditUser() { + if (!editUserId.value) return; + if (!editName.value.trim() || !editMobile.value.trim()) { + error.value = 'نام و شماره موبایل لازم است.'; + return; + } + if (editPassword.value && editPassword.value.length < 8) { + error.value = 'رمز عبور در صورت ورود باید حداقل ۸ کاراکتر باشد.'; + return; + } + editSaving.value = true; + error.value = ''; + try { + const payload: UpdateOrgUserRequest = { + fullName: editName.value.trim(), + mobile: editMobile.value.trim(), + ...(editPassword.value ? { password: editPassword.value } : {}), + }; + await api.patch(`/users/${editUserId.value}`, payload); + editOpen.value = false; + ok.value = 'مشخصات کاربر ذخیره شد.'; + await load(); + } catch (e) { + error.value = apiError(e, 'ویرایش کاربر ناموفق بود'); + } finally { + editSaving.value = false; + } +} + async function toggleStatus(user: OrgUserDto) { menuUserId.value = null; error.value = ''; @@ -852,6 +902,7 @@ onUnmounted(() => { نام و نام خانوادگی + موبایل نقش پورسانت % وضعیت @@ -872,6 +923,7 @@ onUnmounted(() => { {{ user.fullName }} + {{ user.mobile || '—' }} {{ user.orgRoleName || '—' }} @@ -910,6 +962,13 @@ onUnmounted(() => { + @@ -917,7 +976,7 @@ onUnmounted(() => { - کاربری یافت نشد. + کاربری یافت نشد. @@ -932,6 +991,7 @@ onUnmounted(() => { {{ user.orgRoleName || '—' }} +

{{ user.mobile }}

@@ -953,6 +1013,14 @@ onUnmounted(() => { {{ user.isActive === false ? 'غیرفعال' : 'فعال' }} +

کاربری یافت نشد.

@@ -1027,6 +1095,32 @@ onUnmounted(() => { + +
+ + + +

نقش فعلی: {{ editRoleLabel }} — برای تغییر نقش از بخش نقش‌ها استفاده کنید.

+
+ +
+