Allow editing org team member name, mobile, and password from settings.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-04 12:31:07 +03:30
co-authored by Cursor
parent 39e2eb24a6
commit 75ff2c36c4
5 changed files with 219 additions and 1 deletions
+20
View File
@@ -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)
+16
View File
@@ -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(
+75
View File
@@ -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<OrgUserDto> {
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,
+100 -1
View File
@@ -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<string | null>(null);
const editName = ref('');
const editMobile = ref('');
const editPassword = ref('');
const editRoleLabel = ref('');
const roleFormOpen = ref(false);
const roleFormSaving = ref(false);
const roleFormId = ref<string | null>(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(() => {
<thead>
<tr>
<th>نام و نام خانوادگی</th>
<th>موبایل</th>
<th>نقش</th>
<th>پورسانت %</th>
<th>وضعیت</th>
@@ -872,6 +923,7 @@ onUnmounted(() => {
<strong>{{ user.fullName }}</strong>
</div>
</td>
<td class="muted" dir="ltr">{{ user.mobile || '—' }}</td>
<td>
<span class="pill" :data-color="user.orgRoleColor || 'teal'">
{{ user.orgRoleName || '—' }}
@@ -910,6 +962,13 @@ onUnmounted(() => {
<UiIcon name="dots-v" :size="16" />
</button>
</template>
<button
v-if="canInvite"
type="button"
@click="openEditUser(user)"
>
ویرایش مشخصات
</button>
<button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
</button>
@@ -917,7 +976,7 @@ onUnmounted(() => {
</td>
</tr>
<tr v-if="!loading && !pagedUsers.length">
<td colspan="6" class="empty">کاربری یافت نشد.</td>
<td colspan="7" class="empty">کاربری یافت نشد.</td>
</tr>
</tbody>
</table>
@@ -932,6 +991,7 @@ onUnmounted(() => {
<span class="pill" :data-color="user.orgRoleColor || 'teal'">
{{ user.orgRoleName || '—' }}
</span>
<p v-if="user.mobile" class="muted mobile-line" dir="ltr">{{ user.mobile }}</p>
</div>
</div>
<div class="card-meta" @click.stop>
@@ -953,6 +1013,14 @@ onUnmounted(() => {
<i />
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
</span>
<button
v-if="canInvite"
type="button"
class="btn ghost"
@click="openEditUser(user)"
>
ویرایش
</button>
</div>
</article>
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
@@ -1027,6 +1095,32 @@ onUnmounted(() => {
</template>
</UiModal>
<UiModal :open="editOpen" title="ویرایش مشخصات کاربر" @close="editOpen = false">
<form class="form" @submit.prevent="submitEditUser">
<label>نام و نام خانوادگی<input v-model="editName" required minlength="2" /></label>
<label>
شماره موبایل
<input v-model="editMobile" dir="ltr" inputmode="tel" placeholder="0912…" required />
</label>
<label>
رمز عبور جدید
<input
v-model="editPassword"
type="password"
minlength="8"
placeholder="خالی بگذارید اگر تغییر نمی‌دهید"
/>
</label>
<p class="hint">نقش فعلی: {{ editRoleLabel }} — برای تغییر نقش از بخش نقش‌ها استفاده کنید.</p>
</form>
<template #footer>
<button type="button" class="btn ghost" @click="editOpen = false">انصراف</button>
<button type="button" class="btn primary" :disabled="editSaving" @click="submitEditUser">
{{ editSaving ? 'در حال ذخیره…' : 'ذخیره' }}
</button>
</template>
</UiModal>
<UiModal
:open="roleFormOpen"
:title="roleFormId ? 'ویرایش نقش' : roleFormParentId ? 'زیرنقش جدید' : 'نقش جدید'"
@@ -1627,6 +1721,11 @@ tbody tr + tr td {
margin-top: 0.35rem;
}
.mobile-line {
margin: 0.2rem 0 0;
font-size: 0.78rem;
}
.card-meta {
display: grid;
justify-items: end;