feat(roles): let each section decide who may assign work to others

Section access now only creates a record for yourself. Assigning it to a colleague requires a separate permission on that section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-03 13:15:21 +03:30
co-authored by Cursor
parent 79733e6c96
commit 534dae45b2
25 changed files with 355 additions and 84 deletions
+60 -15
View File
@@ -30,6 +30,7 @@ import {
dealAccessWhere,
ownerWhere,
} from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access';
import { normalizeTags } from '../common/tags';
import { GamificationService } from '../gamification/gamification.service';
import { tehranDayKey } from '../routines/routine-dates';
@@ -566,6 +567,12 @@ export class ActivitiesService {
? [dto.assigneeId]
: [userId],
);
await assertAssigneeSet(
this.prisma,
{ userId, role: linkActor.role, organizationId },
'activities',
assigneeIds,
);
const assigneeId = assigneeIds[0] ?? userId;
const initialStageId = emptyToNull(dto.stageId ?? null);
const initialStage = initialStageId
@@ -587,6 +594,12 @@ export class ActivitiesService {
resolved.fields,
dto.fields,
);
await assertAssigneeSet(
this.prisma,
{ userId, role: linkActor.role, organizationId },
'activities',
participantIds,
);
const row = await this.prisma.activity.create({
data: {
@@ -723,6 +736,13 @@ export class ActivitiesService {
data.typeDefId = resolved.typeDefId;
data.fields = fields as Prisma.InputJsonValue;
data.participantIds = participantIds;
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'activities',
participantIds,
existing?.participantIds ?? [],
);
const before = new Set(existing?.participantIds ?? []);
addedParticipants = participantIds.filter((uid) => !before.has(uid) && uid !== actor.userId);
}
@@ -748,22 +768,47 @@ export class ActivitiesService {
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
let assigneeConnect: Array<{ id: string }> | undefined;
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
if (!assigneeIds.length) {
throw new BadRequestException('حداقل یک مسئول لازم است');
if (dto.assigneeIds !== undefined || 'assigneeId' in dto) {
const current = await this.prisma.activity.findFirst({
where: { id, organizationId },
select: { assigneeId: true, assignees: { select: { id: true } } },
});
const previous = current?.assignees.length
? current.assignees.map((item) => item.id)
: current?.assigneeId
? [current.assigneeId]
: [];
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
if (!assigneeIds.length) {
throw new BadRequestException('حداقل یک مسئول لازم است');
}
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'activities',
assigneeIds,
previous,
);
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
} else {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'activities',
[assigneeId],
previous,
);
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
}
data.assigneeId = assigneeIds[0]!;
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
} else if ('assigneeId' in dto) {
const assigneeId = emptyToNull(dto.assigneeId ?? null);
if (assigneeId) await this.assertUserInOrg(organizationId, assigneeId);
data.assigneeId = assigneeId;
if (assigneeId) assigneeConnect = [{ id: assigneeId }];
else assigneeConnect = [];
}
if (dto.status !== undefined) {
data.status = dto.status;
+62
View File
@@ -0,0 +1,62 @@
import { ForbiddenException } from '@nestjs/common';
import {
ASSIGN_SECTION_PERMISSION,
type AssignSection,
} from '@mento/shared';
import type { PrismaService } from '../prisma/prisma.service';
import { elevated } from './record-scope';
const ASSIGN_NOUN: Record<AssignSection, string> = {
customers: 'مشتری',
leads: 'لید',
sales: 'معامله',
activities: 'فعالیت',
routines: 'کار روتین',
projects: 'کارت پروژه',
tickets: 'تیکت',
};
type AssignActor = { userId: string; role: string; organizationId: string };
export function assignOthersDeniedMessage(section: AssignSection): string {
return `تخصیص ${ASSIGN_NOUN[section]} به دیگران برای نقش شما مجاز نیست. فقط می‌توانید برای خودتان ثبت کنید.`;
}
export async function actorCanAssignToOthers(
prisma: PrismaService,
actor: AssignActor,
section: AssignSection,
): Promise<boolean> {
if (elevated(actor.role)) return true;
const membership = await prisma.membership.findFirst({
where: {
userId: actor.userId,
organizationId: actor.organizationId,
isActive: true,
},
select: { orgRole: { select: { permissions: true } } },
});
const permissions = membership?.orgRole?.permissions;
const key = ASSIGN_SECTION_PERMISSION[section];
return Array.isArray(permissions) && permissions.includes(key);
}
/**
* بدون مجوز همان بخش، مجموعهٔ مسئولانِ غیر از خود کاربر نباید عوض شود.
* ثبت تازه یعنی قبلی خالی است، پس فقط خود کاربر مجاز است.
*/
export async function assertAssigneeSet(
prisma: PrismaService,
actor: AssignActor,
section: AssignSection,
nextIds: Array<string | null | undefined>,
previousIds: Array<string | null | undefined> = [],
): Promise<void> {
if (await actorCanAssignToOthers(prisma, actor, section)) return;
const prev = new Set(
previousIds.filter((id): id is string => Boolean(id) && id !== actor.userId),
);
const next = nextIds.filter((id): id is string => Boolean(id) && id !== actor.userId);
const changed = next.length !== prev.size || next.some((id) => !prev.has(id));
if (changed) throw new ForbiddenException(assignOthersDeniedMessage(section));
}
+3 -1
View File
@@ -225,8 +225,10 @@ export function noteAccessWhere(
export function resolveOwnerId(
actor: Pick<Actor, 'userId' | 'role'>,
requested?: string | null,
allowOthers?: boolean,
): string {
if (elevated(actor.role) && requested?.trim()) {
const allowed = allowOthers ?? elevated(actor.role);
if (allowed && requested?.trim()) {
return requested.trim();
}
return actor.userId;
+14 -4
View File
@@ -20,10 +20,10 @@ import {
import { customersCompanyWhere } from '../common/data-access';
import {
type Actor,
canReassignOwner,
elevated,
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { normalizeTags } from '../common/tags';
import { normalizePersonImportSource } from './person-source';
import { buildSearchWhere, ci, splitCsv } from '../common/search';
@@ -201,7 +201,11 @@ export class CompaniesService {
notes: emptyToNull(dto.notes),
registeredAt: parseDateOrNull(dto.registeredAt),
tags: normalizeTags(dto.tags),
ownerId: resolveOwnerId(actor, dto.ownerId),
ownerId: resolveOwnerId(
actor,
dto.ownerId,
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
),
customFields: toJsonCustomFields(dto.customFields),
};
await this.assertNoDuplicate(actor.organizationId, data);
@@ -266,8 +270,14 @@ export class CompaniesService {
if ('customFields' in dto) {
data.customFields = toJsonCustomFields(dto.customFields);
}
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
if (dto.ownerId !== undefined) {
const current = await this.prisma.company.findFirst({
where: { id, organizationId: actor.organizationId },
select: { ownerId: true },
});
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
data.ownerId = nextOwner ?? actor.userId;
}
await this.assertNoDuplicate(actor.organizationId, data, id);
+14 -4
View File
@@ -38,9 +38,9 @@ import {
import { customersPersonWhere } from '../common/data-access';
import {
type Actor,
canReassignOwner,
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { LoyaltyService } from '../loyalty/loyalty.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
@@ -453,7 +453,11 @@ export class PeopleService implements OnModuleInit {
mood: dto.mood ?? 'newcomer',
tags: normalizeTags(dto.tags),
companyId,
ownerId: resolveOwnerId(actor, dto.ownerId),
ownerId: resolveOwnerId(
actor,
dto.ownerId,
await actorCanAssignToOthers(this.prisma, actor, 'customers'),
),
customFields: toJsonCustomFields(dto.customFields),
...(registeredAt && !Number.isNaN(registeredAt.getTime())
? { createdAt: registeredAt }
@@ -574,8 +578,14 @@ export class PeopleService implements OnModuleInit {
}
data.companyId = nextCompanyId;
}
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
if (dto.ownerId !== undefined) {
const current = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId },
select: { ownerId: true },
});
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(this.prisma, actor, 'customers', [nextOwner], [current?.ownerId]);
data.ownerId = nextOwner ?? actor.userId;
}
await this.assertNoDuplicate(actor.organizationId, data, id);
-2
View File
@@ -302,8 +302,6 @@ export class LeadsController {
}
@Post('assign')
@UseGuards(RolesGuard)
@Roles('owner', 'admin')
assign(@CurrentUser() user: Actor, @Body() dto: AssignLeadsDto) {
return this.leads
.assign(user, dto.leadIds, dto.assigneeId)
+7 -6
View File
@@ -31,6 +31,7 @@ import {
elevated,
forScope,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import {
asAddresses,
collectAddresses,
@@ -379,10 +380,11 @@ export class LeadsService {
const stageId = dto.stageId || stages[0]?.id;
if (!stageId) throw new BadRequestException('مرحله‌ای تعریف نشده');
const allowOthers = await actorCanAssignToOthers(this.prisma, actor, 'leads');
let assigneeId: string | null = null;
if (elevated(actor.role) && dto.assigneeId) {
if (allowOthers && dto.assigneeId) {
assigneeId = dto.assigneeId;
} else if (!elevated(actor.role)) {
} else if (!allowOthers) {
assigneeId = actor.userId;
}
@@ -570,8 +572,9 @@ export class LeadsService {
if (typeof dto.stageId === 'string' && dto.stageId !== existing.stageId) {
data.stage = { connect: { id: dto.stageId } };
}
if (elevated(actor.role) && dto.assigneeId !== undefined) {
if (dto.assigneeId !== undefined) {
const aid = dto.assigneeId ? String(dto.assigneeId) : null;
await assertAssigneeSet(this.prisma, actor, 'leads', [aid], [existing.assigneeId]);
data.assignee = aid
? { connect: { id: aid } }
: { disconnect: true };
@@ -641,9 +644,7 @@ export class LeadsService {
leadIds: string[],
assigneeId: string,
): Promise<number> {
if (!elevated(actor.role)) {
throw new BadRequestException('فقط مدیر می‌تواند تخصیص دهد');
}
await assertAssigneeSet(this.prisma, actor, 'leads', [assigneeId]);
const user = await this.prisma.user.findFirst({
where: {
id: assigneeId,
+3 -1
View File
@@ -41,7 +41,9 @@ export const SYSTEM_ROLE_SEEDS: Array<{
name: 'کارشناس فروش',
description: 'امکان دسترسی به بخش‌های فروش و مشتریان برای انجام فعالیت‌های روزمره.',
color: 'teal',
permissions: ALL_ROLE_PERMISSIONS.filter((key) => key !== 'audit'),
permissions: ALL_ROLE_PERMISSIONS.filter(
(key) => key !== 'audit' && !key.startsWith('assign_'),
),
},
{
systemKey: 'support',
+37 -12
View File
@@ -22,11 +22,11 @@ import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
import {
type Actor,
canReassignOwner,
dealAccessWhere,
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
const dealInclude = {
person: { select: { id: true, firstName: true, lastName: true } },
@@ -106,6 +106,10 @@ export class DealsService {
organizationId,
dto.assigneeIds,
);
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
const allowOthers = await actorCanAssignToOthers(this.prisma, assignActor, 'sales');
const ownerId = resolveOwnerId(actor, dto.ownerId, allowOthers);
await assertAssigneeSet(this.prisma, assignActor, 'sales', [ownerId, ...assigneeIds]);
const maxPos = await this.prisma.deal.aggregate({
where: { organizationId, stageId: stage.id },
@@ -156,7 +160,7 @@ export class DealsService {
createdById: actor.userId,
personId: emptyToNull(dto.personId ?? null),
companyId: emptyToNull(dto.companyId ?? null),
ownerId: resolveOwnerId(actor, dto.ownerId),
ownerId,
position: (maxPos._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
@@ -259,17 +263,38 @@ export class DealsService {
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if (dto.ownerId !== undefined && canReassignOwner(actor.role)) {
data.ownerId = resolveOwnerId(actor, dto.ownerId);
}
let assigneeConnect: { id: string }[] | undefined;
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
if (dto.ownerId !== undefined || dto.assigneeIds !== undefined) {
const current = await this.prisma.deal.findFirst({
where: { id, organizationId },
select: { ownerId: true, assignees: { select: { id: true } } },
});
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
if (dto.ownerId !== undefined) {
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(
this.prisma,
assignActor,
'sales',
[nextOwner],
[current?.ownerId],
);
data.ownerId = nextOwner ?? actor.userId;
}
if (dto.assigneeIds !== undefined) {
const assigneeIds = await this.normalizeAssigneeIds(
organizationId,
dto.assigneeIds,
);
await assertAssigneeSet(
this.prisma,
assignActor,
'sales',
assigneeIds,
current?.assignees.map((item) => item.id) ?? [],
);
assigneeConnect = assigneeIds.map((uid) => ({ id: uid }));
}
}
if (pricedFromLines && dto.lineItems) {
@@ -15,12 +15,12 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
canReassignOwner,
dealAccessWhere,
ownerWhere,
projectCardAccessWhere,
resolveOwnerId,
} from '../common/record-scope';
import { actorCanAssignToOthers, assertAssigneeSet } from '../common/assign-access';
import { normalizeTags } from '../common/tags';
const DEFAULT_STAGES = ['صف', 'در حال انجام', 'بازبینی', 'انجام‌شده'] as const;
@@ -206,6 +206,13 @@ export class ProjectBoardsService {
});
const assigneeIds = [...new Set((dto.assigneeIds ?? []).map((id) => id.trim()).filter(Boolean))];
const assignActor = { userId: actor.userId, role: actor.role, organizationId };
const ownerId = resolveOwnerId(
actor,
dto.ownerId,
await actorCanAssignToOthers(this.prisma, assignActor, 'projects'),
);
await assertAssigneeSet(this.prisma, assignActor, 'projects', [ownerId, ...assigneeIds]);
const created = await this.prisma.projectCard.create({
data: {
organizationId,
@@ -217,7 +224,7 @@ export class ProjectBoardsService {
personId: emptyToNull(dto.personId),
companyId: emptyToNull(dto.companyId),
dealId: emptyToNull(dto.dealId),
ownerId: resolveOwnerId(actor, dto.ownerId),
ownerId,
position: (maxPos._max.position ?? -1) + 1,
...(assigneeIds.length
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
@@ -257,24 +264,40 @@ export class ProjectBoardsService {
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if ('ownerId' in dto) {
if (!canReassignOwner(actor.role) && dto.ownerId !== existing.ownerId) {
throw new BadRequestException('تغییر مسئول مجاز نیست');
}
data.ownerId = emptyToNull(dto.ownerId ?? null);
const nextOwner = emptyToNull(dto.ownerId ?? null);
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'projects',
[nextOwner],
[existing.ownerId],
);
data.ownerId = nextOwner;
}
const nextAssigneeIds = dto.assigneeIds
? [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))]
: undefined;
if (nextAssigneeIds) {
const currentAssignees = await this.prisma.projectCard.findFirst({
where: { id },
select: { assignees: { select: { id: true } } },
});
await assertAssigneeSet(
this.prisma,
{ userId: actor.userId, role: actor.role, organizationId },
'projects',
nextAssigneeIds,
currentAssignees?.assignees.map((item) => item.id) ?? [],
);
}
const row = await this.prisma.projectCard.update({
where: { id },
data: {
...data,
...(dto.assigneeIds
? {
assignees: {
set: [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))].map(
(uid) => ({ id: uid }),
),
},
}
...(nextAssigneeIds
? { assignees: { set: nextAssigneeIds.map((uid) => ({ id: uid })) } }
: {}),
},
include: cardInclude,
@@ -13,6 +13,7 @@ import type { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import type { Actor } from '../common/record-scope';
import { elevated } from '../common/record-scope';
import { actorCanAssignToOthers, assignOthersDeniedMessage } from '../common/assign-access';
import {
ChangeRoutineAssigneeDto,
ListRoutinesQueryDto,
@@ -360,6 +361,11 @@ export class RoutinesService {
assigneeId?: string | null,
teamId?: string | null,
): Promise<{ assigneeId: string | null; teamId: string | null }> {
if ((assigneeId && assigneeId !== actor.userId) || teamId) {
if (!(await actorCanAssignToOthers(this.prisma, actor, 'routines'))) {
throw new ForbiddenException(assignOthersDeniedMessage('routines'));
}
}
if (assigneeId) {
if (!canManage(scope, assigneeId)) {
throw new ForbiddenException('اجازه تعریف روتین برای این کاربر را ندارید');
+3 -6
View File
@@ -20,6 +20,7 @@ import {
elevated,
ticketAccessWhere,
} from '../common/record-scope';
import { assertAssigneeSet } from '../common/assign-access';
import { NotificationsService } from '../notifications/notifications.service';
import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
@@ -136,9 +137,7 @@ export class TicketsService {
if (assigneeId) {
await this.assertUser(actor.organizationId, assigneeId);
}
if (!elevated(actor.role) && assigneeId && assigneeId !== actor.userId) {
assigneeId = actor.userId;
}
await assertAssigneeSet(this.prisma, actor, 'tickets', [assigneeId]);
const number = await this.nextNumber(actor.organizationId);
const status = (dto.status as never) || 'open';
@@ -270,9 +269,7 @@ export class TicketsService {
if ('assigneeId' in dto) {
const next = emptyToNull(dto.assigneeId ?? null);
if (next) await this.assertUser(actor.organizationId, next);
if (!elevated(actor.role) && next && next !== actor.userId) {
throw new BadRequestException('فقط می‌توانید تیکت را به خودتان ارجاع دهید');
}
await assertAssigneeSet(this.prisma, actor, 'tickets', [next], [existing.assigneeId]);
data.assigneeId = next;
if (next && next !== existing.assigneeId && next !== actor.userId) {
await this.notifications.create({
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import type {
ActivityChecklistItem,
ActivityFieldDef,
@@ -11,6 +11,8 @@ import type {
PersonDto,
} from '@mento/shared';
import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import { toFaDigits } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import UserMultiSelect from '@/components/UserMultiSelect.vue';
@@ -22,6 +24,8 @@ const props = defineProps<{
}>();
const model = defineModel<ActivityFieldValues>({ required: true });
const auth = useAuthStore();
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'activities'));
function get<T extends ActivityFieldValue>(key: string, fallback: T): T {
const v = model.value[key];
@@ -304,6 +308,7 @@ watch(
<UserMultiSelect
:model-value="participants(f.key).userIds"
:users="users"
:disabled="!canAssignOthers"
add-label="افزودن همکار"
empty-label="همکاری انتخاب نشده"
@update:model-value="(ids: string[]) => patchParticipants(f.key, { userIds: ids })"
@@ -33,6 +33,7 @@ import {
todayKey,
} from '@/lib/routines';
import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access';
const { Text: ATypographyText } = Typography;
@@ -117,7 +118,7 @@ watch(
const assignees = computed(() => {
const c = ctx.value;
if (!c?.isManager) return [];
if (!c?.isManager || !canAssignToOthers(auth.user, 'routines')) return [];
return c.users;
});
@@ -22,6 +22,8 @@ import {
UiTextarea,
} from '@/components/ui';
import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import { formatJalaliDayClock } from '@/lib/jalali';
import {
ENTITY_TYPE_LABELS,
@@ -103,7 +105,9 @@ const customerTask = computed(() => {
return (t.source !== 'routine' && t.source !== 'manual') || t.entities.length > 0;
});
const done = computed(() => task.value?.status === 'completed');
const auth = useAuthStore();
const canManage = computed(() => !!shell?.context.value?.isManager);
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'routines'));
const priorities = Object.entries(PRIORITY_LABELS) as Array<[ActivityPriority, string]>;
function startEdit() {
@@ -276,7 +280,7 @@ function eventText(ev: RoutineTaskEventDto): string {
<JalaliDateInput v-model="edit.time" mode="time" />
</UiField>
</template>
<UiField v-if="canManage" label="مسئول" class="span-2">
<UiField v-if="canManage && canAssignOthers" label="مسئول" class="span-2">
<UiSelect v-model="edit.assigneeId">
<option v-for="u in shell?.context.value?.users ?? []" :key="u.id" :value="u.id">
{{ u.fullName }}
+20
View File
@@ -0,0 +1,20 @@
import {
ASSIGN_SECTION_PERMISSION,
type AssignSection,
type RolePermissionKey,
} from '@mento/shared';
export function canAssignToOthers(
user:
| {
role?: string | null;
permissions?: Array<RolePermissionKey | string> | null;
}
| null
| undefined,
section: AssignSection,
): boolean {
if (!user) return false;
if (user.role === 'owner' || user.role === 'admin') return true;
return (user.permissions ?? []).includes(ASSIGN_SECTION_PERMISSION[section]);
}
+2 -2
View File
@@ -22,7 +22,7 @@ import type {
OrgUserDto,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canReassignRecords } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import { brand } from '@mento/shared';
@@ -51,7 +51,7 @@ function goBack() {
}
const auth = useAuthStore();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const canReassign = computed(() => canReassignRecords(auth.user?.role));
const canReassign = computed(() => canAssignToOthers(auth.user, 'activities'));
const loading = ref(true);
const saving = ref(false);
+9 -3
View File
@@ -4,6 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access';
import {
formatJalaliDayClock,
formatJalaliNumeric,
@@ -175,7 +176,10 @@ async function ensurePersonLoaded(pid: string) {
}
}
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'activities'));
function toggleAssigneeMenu() {
if (!canAssignOthers.value) return;
assigneeOpen.value = !assigneeOpen.value;
if (assigneeOpen.value) {
typeOpen.value = false;
@@ -1263,9 +1267,11 @@ watch([id, isNew], () => void load(), { immediate: true });
</div>
<small class="hint">
{{
teamGroups.length
? 'یک تیم یا نقش را انتخاب کنید تا همهٔ اعضایش مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
: 'یک نقش را انتخاب کنید تا همهٔ اعضای آن مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
canAssignOthers
? teamGroups.length
? 'یک تیم یا نقش را انتخاب کنید تا همهٔ اعضایش مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
: 'یک نقش را انتخاب کنید تا همهٔ اعضای آن مسئول شوند، یا چند نفر را جدا انتخاب کنید.'
: 'بدون دسترسی «تخصیص فعالیت به دیگران» این فعالیت فقط برای خودتان ثبت می‌شود.'
}}
</small>
<div v-if="selectedRoles.length || selectedTeams.length || extraAssignees.length" class="assignee-chips">
+4 -1
View File
@@ -38,6 +38,7 @@ import {
} from '@ant-design/icons-vue';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canAssignToOthers } from '@/lib/assign-access';
import { canUseBehavior } from '@/lib/behavior-access';
import { toFaDigits } from '@/lib/jalali';
import { currencySuffix, formatMoney } from '@/lib/money';
@@ -180,6 +181,7 @@ const personSelectOptions = computed(() =>
const companySelectOptions = computed(() =>
companies.value.map((c) => ({ value: c.id, label: c.name })),
);
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'sales'));
const ownerSelectOptions = computed(() =>
users.value.map((u) => ({ value: u.id, label: u.fullName })),
);
@@ -772,13 +774,14 @@ onMounted(load);
v-model:value="ownerId"
show-search
option-filter-prop="label"
:disabled="!canAssignOthers"
:options="ownerSelectOptions"
placeholder="انتخاب مسئول"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-col v-if="canAssignOthers" :xs="24" :md="12">
<a-form-item label="مسئولان مشترک">
<UserMultiSelect v-model="selectedAssigneeIds" :users="users" />
<a-typography-text type="secondary">علاوه بر مسئول اصلی.</a-typography-text>
+4 -4
View File
@@ -55,7 +55,7 @@ import {
toFaDigits,
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
import SmsComposeModal from '@/components/SmsComposeModal.vue';
@@ -82,7 +82,7 @@ type LeadDetail = LeadDto & { events: LeadEventDto[] };
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'leads'));
const canBehavior = computed(() => canUseBehavior(auth.user));
const copilotOpen = ref(false);
@@ -941,7 +941,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
اعمال وضعیت
</a-button>
<template v-if="isAdmin">
<template v-if="canAssignOthers">
<a-divider orientation="right">تخصیص کارشناس</a-divider>
<a-form-item label="کارشناس">
<a-select
@@ -1085,7 +1085,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
</a-form-item>
<a-button block :disabled="busy" @click="changeStage">اعمال وضعیت</a-button>
<template v-if="isAdmin">
<template v-if="canAssignOthers">
<a-divider orientation="right">تخصیص کارشناس</a-divider>
<a-form-item label="کارشناس">
<a-select
+7 -2
View File
@@ -58,6 +58,7 @@ import {
} from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
@@ -147,6 +148,7 @@ const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => canManageSettings(auth.user?.role));
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'leads'));
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
const loading = ref(false);
@@ -1355,7 +1357,7 @@ onBeforeUnmount(() => {
<a-typography-text>
{{ toFaDigits(selectedIds.length) }} مورد انتخاب‌شده
</a-typography-text>
<template v-if="isAdmin">
<template v-if="canAssignOthers">
<a-select
v-model:value="assignUserId"
placeholder="تخصیص به کارشناس"
@@ -1364,6 +1366,8 @@ onBeforeUnmount(() => {
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
/>
<a-button :disabled="!assignUserId || busy" @click="assignSelected">اعمال تخصیص</a-button>
</template>
<template v-if="isAdmin">
<a-select
v-model:value="bulkStageId"
placeholder="تغییر وضعیت"
@@ -1755,7 +1759,8 @@ onBeforeUnmount(() => {
<a-select
v-model:value="createForm.assigneeId"
allow-clear
placeholder="بدون مسئول"
:disabled="!canAssignOthers"
:placeholder="canAssignOthers ? 'بدون مسئول' : 'خودتان'"
style="width: 100%"
:options="users.map((u) => ({ value: u.id, label: u.fullName }))"
/>
+7 -2
View File
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import type { OrgUserDto, ProjectCardDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
@@ -21,6 +23,8 @@ const emit = defineEmits<{
cancel: [];
}>();
const auth = useAuthStore();
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'projects'));
const route = useRoute();
const router = useRouter();
const id = computed(() => {
@@ -231,10 +235,11 @@ onMounted(async () => {
</div>
<div class="field">
<label for="card-assignees">مسئولان کارت</label>
<select id="card-assignees" v-model="selectedAssigneeIds" multiple class="multi">
<select id="card-assignees" v-model="selectedAssigneeIds" multiple class="multi" :disabled="!canAssignOthers">
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</select>
<small class="hint">با نگه داشتن Ctrl می‌توانید چند نفر را انتخاب کنید.</small>
<small v-if="canAssignOthers" class="hint">با نگه داشتن Ctrl می‌توانید چند نفر را انتخاب کنید.</small>
<small v-else class="hint">بدون دسترسی «تخصیص کارت پروژه به دیگران» کارت فقط برای خودتان ثبت می‌شود.</small>
</div>
<div class="field">
<label for="card-notes">یادداشت</label>
+7
View File
@@ -56,11 +56,18 @@ const router = useRouter();
/** مجوزی که ماژولش در سوپرادمین خاموش است در نقش‌ها نشان داده نمی‌شود. */
const PERMISSION_MODULES: Partial<Record<RolePermissionKey, OrgModuleKey[]>> = {
customers: ['customers'],
assign_customers: ['customers'],
leads: ['leads'],
assign_leads: ['leads'],
sales: ['pipeline'],
assign_sales: ['pipeline'],
billing: ['invoices', 'quotes'],
products: ['products'],
activities: ['activities'],
assign_activities: ['activities'],
assign_routines: ['routines'],
assign_projects: ['project_boards'],
assign_tickets: ['tickets'],
notes: ['notes'],
reports: ['reports'],
audit: ['audit_logs'],
+2 -2
View File
@@ -4,7 +4,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import { canViewAllRecords } from '@/lib/roles';
import { canAssignToOthers } from '@/lib/assign-access';
import { useAuthStore } from '@/stores/auth';
import {
UiBadge,
@@ -61,7 +61,7 @@ const faqHints = ref<FaqArticleDto[]>([]);
const csatScore = ref<number | null>(null);
const csatComment = ref('');
const canAssignOthers = computed(() => canViewAllRecords(auth.user?.role));
const canAssignOthers = computed(() => canAssignToOthers(auth.user, 'tickets'));
const showCsat = computed(() => {
if (!ticket.value || isNew.value) return false;
+34
View File
@@ -723,11 +723,18 @@ export function migrateVisibilityToCustomersAccess(
export type RolePermissionKey =
| "customers"
| "assign_customers"
| "leads"
| "assign_leads"
| "sales"
| "assign_sales"
| "billing"
| "products"
| "activities"
| "assign_activities"
| "assign_routines"
| "assign_projects"
| "assign_tickets"
| "notes"
| "reports"
| "audit"
@@ -737,16 +744,43 @@ export type RolePermissionKey =
| "treasury_payroll"
| "behavior";
/** بخشی که مسئولش را می‌شود به کاربر دیگری داد */
export type AssignSection =
| "customers"
| "leads"
| "sales"
| "activities"
| "routines"
| "projects"
| "tickets";
export const ASSIGN_SECTION_PERMISSION: Record<AssignSection, RolePermissionKey> = {
customers: "assign_customers",
leads: "assign_leads",
sales: "assign_sales",
activities: "assign_activities",
routines: "assign_routines",
projects: "assign_projects",
tickets: "assign_tickets",
};
export const ROLE_PERMISSION_ITEMS: Array<{
key: RolePermissionKey;
label: string;
}> = [
{ key: "customers", label: "مشتریان" },
{ key: "assign_customers", label: "تخصیص مشتری به دیگران" },
{ key: "leads", label: "لیدها" },
{ key: "assign_leads", label: "تخصیص لید به دیگران" },
{ key: "sales", label: "فروش و معاملات" },
{ key: "assign_sales", label: "تخصیص معامله به دیگران" },
{ key: "billing", label: "پیش‌فاکتورها و فاکتورها" },
{ key: "products", label: "محصولات و خدمات" },
{ key: "activities", label: "فعالیت‌ها" },
{ key: "assign_activities", label: "تخصیص فعالیت به دیگران" },
{ key: "assign_routines", label: "تخصیص کار روتین به دیگران" },
{ key: "assign_projects", label: "تخصیص کارت پروژه به دیگران" },
{ key: "assign_tickets", label: "تخصیص تیکت به دیگران" },
{ key: "notes", label: "یادداشت‌ها" },
{ key: "reports", label: "گزارش‌ها" },
{ key: "audit", label: "لاگ فعالیت کاربران" },