feat(activities): let creators lock edit and delete to themselves

Add editPolicy (default creator-only), enforce it in the API, surface clear permission messages, and drop the redundant edit menu now that detail is editable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-11 15:56:36 +03:30
co-authored by Cursor
parent ec9a7cf9c9
commit 1a05eee46d
10 changed files with 279 additions and 57 deletions
+67 -37
View File
@@ -52,7 +52,10 @@ import type {
} from '@mento/shared';
import { brand } from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canMutateActivity } from '@/lib/activity-access';
import { canManageSettings, canViewAllRecords } from '@/lib/roles';
import { apiError } from '@/lib/routines';
import { message } from 'ant-design-vue';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
@@ -446,23 +449,11 @@ function deskDetailQuery(columnStageId?: string): Record<string, string> {
return query;
}
function activityRoute(id: string, edit = false, columnStageId?: string) {
if (!edit) {
return {
name: 'activity-detail' as const,
params: { id },
query: deskDetailQuery(columnStageId),
};
}
const query: Record<string, string> = {};
for (const key of ['board', 'view', 'desk', 'filter', 'stage', 'q', 'typeDefId', 'status', 'assigneeId', 'title', 'customer', 'due'] as const) {
const value = route.query[key];
if (typeof value === 'string' && value) query[key] = value;
}
function activityRoute(id: string, columnStageId?: string) {
return {
name: 'activity-edit' as const,
name: 'activity-detail' as const,
params: { id },
query,
query: deskDetailQuery(columnStageId),
};
}
@@ -938,10 +929,27 @@ function clearSelect() {
selectedIds.value = new Set();
}
function canDeleteActivity(item: ActivityDto | null | undefined) {
return canMutateActivity(item, auth.user);
}
function activityById(id: string) {
return (
items.value.find((row) => row.id === id) ||
(boardMenuActivity.value?.id === id ? boardMenuActivity.value : null)
);
}
function askDeleteActivity(id: string, title?: string) {
menuId.value = null;
boardMenuId.value = null;
const target = activityById(id);
boardMenuActivity.value = null;
if (target && !canDeleteActivity(target)) {
message.warning('شما مجوز حذف این فعالیت را ندارید.');
error.value = 'شما مجوز حذف این فعالیت را ندارید.';
return;
}
deleteSingleTitle.value = title?.trim() || activityTitle(id);
deleteActivityIds.value = [id];
}
@@ -951,8 +959,24 @@ function askBulkDelete() {
menuId.value = null;
boardMenuId.value = null;
boardMenuActivity.value = null;
const selected = [...selectedIds.value];
const allowed = selected.filter((id) => {
const row = activityById(id);
return !row || canDeleteActivity(row);
});
const blocked = selected.length - allowed.length;
if (!allowed.length) {
message.warning('شما مجوز حذف این فعالیت‌ها را ندارید.');
error.value = 'شما مجوز حذف این فعالیت‌ها را ندارید.';
return;
}
if (blocked > 0) {
message.warning(
`${toFaDigits(blocked)} مورد به‌خاطر نبود مجوز حذف کنار گذاشته شد.`,
);
}
deleteSingleTitle.value = '';
deleteActivityIds.value = [...selectedIds.value];
deleteActivityIds.value = allowed;
}
async function confirmDeleteActivities() {
@@ -962,16 +986,24 @@ async function confirmDeleteActivities() {
error.value = '';
try {
const results = await Promise.allSettled(ids.map((id) => api.delete(`/activities/${id}`)));
const failed = results.filter((r) => r.status === 'rejected').length;
const failed = results.filter((r) => r.status === 'rejected');
deleteActivityIds.value = [];
clearSelect();
kanbanReload.value += 1;
await refreshAll();
if (failed) {
if (failed.length) {
const first = failed[0];
const detail =
first && first.status === 'rejected'
? apiError(first.reason, '')
: '';
error.value =
failed === ids.length
? 'حذف فعالیت‌ها ناموفق بود.'
: `${toFaDigits(failed)} مورد حذف نشد.`;
failed.length === ids.length
? detail || 'شما مجوز حذف این فعالیت را ندارید.'
: detail
? `${toFaDigits(failed.length)} مورد حذف نشد: ${detail}`
: `${toFaDigits(failed.length)} مورد حذف نشد.`;
message.error(error.value);
}
} finally {
activityDeleting.value = false;
@@ -1776,16 +1808,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)">
مشاهده
</RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</button>
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
{{ completeLabel(item) }}
</button>
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
<button
v-if="canDeleteActivity(item)"
type="button"
class="danger"
@click="askDeleteActivity(item.id, item.title)"
>
حذف
</button>
</UiDropdownMenu>
@@ -1866,16 +1900,18 @@ onUnmounted(() => {
<RouterLink :to="activityRoute(item.id)">
مشاهده
</RouterLink>
<RouterLink :to="activityRoute(item.id, true)">
ویرایش
</RouterLink>
<button v-if="canPromotePrior(item)" type="button" @click="promotePriorActivity(item.id)">
انتقال به کار جاری
</button>
<button v-if="canMarkShare(item)" type="button" @click="complete(item.id)">
{{ completeLabel(item) }}
</button>
<button type="button" class="danger" @click="askDeleteActivity(item.id, item.title)">
<button
v-if="canDeleteActivity(item)"
type="button"
class="danger"
@click="askDeleteActivity(item.id, item.title)"
>
حذف
</button>
</UiDropdownMenu>
@@ -1922,18 +1958,11 @@ onUnmounted(() => {
<button type="button" role="menuitem" @click="startSelect(boardMenuId)">انتخاب</button>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId, false, viewMode === 'board' ? boardMenuStageId : undefined)"
:to="activityRoute(boardMenuId, viewMode === 'board' ? boardMenuStageId : undefined)"
@click="boardMenuId = null; boardMenuActivity = null; boardMenuStageId = ''"
>
مشاهده
</RouterLink>
<RouterLink
role="menuitem"
:to="activityRoute(boardMenuId, true)"
@click="boardMenuId = null; boardMenuActivity = null"
>
ویرایش
</RouterLink>
<button
v-if="boardMenuActivity && canPromotePrior(boardMenuActivity)"
type="button"
@@ -1943,6 +1972,7 @@ onUnmounted(() => {
انتقال به کار جاری
</button>
<button
v-if="canDeleteActivity(boardMenuActivity)"
type="button"
role="menuitem"
class="danger"
+42 -16
View File
@@ -29,7 +29,9 @@ import type {
TeamDto,
} from '@mento/shared';
import { useAuthStore } from '@/stores/auth';
import { canMutateActivity } from '@/lib/activity-access';
import { canAssignToOthers } from '@/lib/assign-access';
import { apiError } from '@/lib/routines';
import { activityTypeView, typeColorStyle, useActivityTypes } from '@/lib/activity-types';
import {
bindThreadMedia,
@@ -76,12 +78,16 @@ async function goBack() {
}
const auth = useAuthStore();
const id = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const canReassign = computed(() => canAssignToOthers(auth.user, 'activities'));
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const activity = ref<ActivityDto | null>(null);
const canMutate = computed(() => canMutateActivity(activity.value, auth.user));
const editLocked = computed(() => saving.value || !canMutate.value);
const canReassign = computed(
() => canMutate.value && canAssignToOthers(auth.user, 'activities'),
);
const boardStages = ref<ActivityStageDto[]>([]);
const stageMoving = ref(false);
type NoteMedia = {
@@ -429,7 +435,9 @@ function clearNoteEdit() {
}
function canEditNote(entry: ActivityThreadEntry): boolean {
return Boolean(entry.dateLabel && meName.value && entry.author === meName.value);
return Boolean(
canMutate.value && entry.dateLabel && meName.value && entry.author === meName.value,
);
}
function activityMediaCount(kind: 'image' | 'audio' | 'file') {
@@ -557,8 +565,8 @@ async function saveNoteEdit(): Promise<boolean> {
clearNoteEdit();
await load();
return true;
} catch {
error.value = 'ویرایش یادداشت ناموفق بود.';
} catch (e) {
error.value = apiError(e, 'شما مجوز ویرایش این فعالیت را ندارید.');
return false;
} finally {
saving.value = false;
@@ -567,6 +575,7 @@ async function saveNoteEdit(): Promise<boolean> {
async function persistDrafts(): Promise<boolean> {
if (!id.value || !activity.value) return true;
if (!canMutate.value) return true;
if (editingNoteIndex.value !== null) {
const noteOk = await saveNoteEdit();
if (!noteOk) return false;
@@ -598,8 +607,8 @@ async function persistDrafts(): Promise<boolean> {
activity.value = data;
hydrateDraft(data);
return true;
} catch {
error.value = 'ثبت تغییرات ناموفق بود.';
} catch (e) {
error.value = apiError(e, 'شما مجوز ویرایش این فعالیت را ندارید.');
return false;
} finally {
saving.value = false;
@@ -846,6 +855,10 @@ async function setCompletionMode(mode: 'any' | 'all') {
async function reopen() {
if (!id.value || !activity.value || activity.value.status === 'open') return;
if (!canMutate.value) {
error.value = 'شما مجوز ویرایش این فعالیت را ندارید.';
return;
}
saving.value = true;
error.value = '';
try {
@@ -887,8 +900,8 @@ async function postponeTo(iso: string) {
await api.patch(`/activities/${id.value}`, { dueAt: iso });
postponeOpen.value = false;
await load();
} catch {
error.value = 'تعویق فعالیت ناموفق بود.';
} catch (e) {
error.value = apiError(e, 'شما مجوز ویرایش این فعالیت را ندارید.');
} finally {
saving.value = false;
}
@@ -910,7 +923,7 @@ async function submitComment(payload: { text: string; photoOnly: boolean }) {
commentOpen.value = false;
await load();
} catch (e) {
error.value = 'ثبت نظر ناموفق بود.';
error.value = apiError(e, 'شما مجوز ویرایش این فعالیت را ندارید.');
throw e;
} finally {
saving.value = false;
@@ -1056,9 +1069,12 @@ onUnmounted(() => {
type="text"
maxlength="200"
aria-label="عنوان فعالیت"
:disabled="saving"
:disabled="editLocked"
/>
</h2>
<p v-if="!canMutate" class="error" role="status">
شما مجوز ویرایش یا حذف این فعالیت را ندارید.
</p>
<p v-if="subtitle" class="sub">
<UiIcon name="building" :size="14" />
{{ subtitle }}
@@ -1097,7 +1113,7 @@ onUnmounted(() => {
نوع فعالیت
</dt>
<dd>
<select v-model="draftTypeDefId" class="meta-control" :disabled="saving" aria-label="نوع فعالیت">
<select v-model="draftTypeDefId" class="meta-control" :disabled="editLocked" aria-label="نوع فعالیت">
<option v-for="opt in typeChoices" :key="opt.id" :value="opt.id">{{ opt.name }}</option>
</select>
</dd>
@@ -1108,7 +1124,7 @@ onUnmounted(() => {
تاریخ و زمان
</dt>
<dd>
<JalaliDateInput v-model="draftDue" mode="datetime" :disabled="saving" />
<JalaliDateInput v-model="draftDue" mode="datetime" :disabled="editLocked" />
</dd>
</div>
<div>
@@ -1117,7 +1133,7 @@ onUnmounted(() => {
اولویت
</dt>
<dd>
<select v-model="draftPriority" class="meta-control" :disabled="saving" aria-label="اولویت">
<select v-model="draftPriority" class="meta-control" :disabled="editLocked" aria-label="اولویت">
<option v-for="(label, key) in priorityLabel" :key="key" :value="key">{{ label }}</option>
</select>
</dd>
@@ -1630,7 +1646,7 @@ onUnmounted(() => {
v-if="isDone || activity.status === 'canceled'"
class="act primary"
type="button"
:disabled="saving"
:disabled="editLocked"
@click="reopen"
>
<UiIcon name="clock" :size="16" />
@@ -1656,11 +1672,21 @@ onUnmounted(() => {
<UiIcon name="check" :size="16" />
انجام شده
</button>
<button class="act ghost" type="button" :disabled="saving || isDone" @click="postponeOpen = true">
<button
class="act ghost"
type="button"
:disabled="editLocked || isDone"
@click="postponeOpen = true"
>
<UiIcon name="refresh" :size="16" />
تعویق
</button>
<button class="act ghost" type="button" :disabled="saving" @click="commentOpen = true">
<button
class="act ghost"
type="button"
:disabled="editLocked"
@click="commentOpen = true"
>
<UiIcon name="chat" :size="16" />
ثبت نظر
</button>
+27
View File
@@ -130,6 +130,7 @@ const leadId = ref('');
const dealId = ref('');
const assigneeIds = ref<string[]>([]);
const completionMode = ref<'any' | 'all'>('any');
const editPolicy = ref<'creator' | 'assignees'>('creator');
const isDone = ref(false);
const needsNote = ref(false);
const reminderAt = ref('');
@@ -442,6 +443,7 @@ function resetForm() {
dealId.value = '';
assigneeIds.value = auth.user?.id ? [auth.user.id] : [];
completionMode.value = 'any';
editPolicy.value = 'creator';
pickedRoleIds.value = [];
pickedTeamIds.value = [];
isDone.value = false;
@@ -531,6 +533,7 @@ async function load() {
? [auth.user.id]
: [];
completionMode.value = data.completionMode === 'all' ? 'all' : 'any';
editPolicy.value = data.editPolicy === 'assignees' ? 'assignees' : 'creator';
refreshPickedGroups();
isDone.value = data.status === 'done';
await loadSavedPhotos(data);
@@ -619,6 +622,7 @@ function payload(): CreateActivityRequest {
assigneeIds: [...assigneeIds.value],
assigneeId: assigneeIds.value[0] || null,
completionMode: assigneeIds.value.length > 1 ? completionMode.value : 'any',
editPolicy: editPolicy.value,
};
}
@@ -1332,6 +1336,29 @@ watch([id, isNew], () => void load(), { immediate: true });
</div>
</div>
<div class="card">
<div class="field">
<label>ویرایش و حذف</label>
<div class="completion-mode">
<label>
<input v-model="editPolicy" type="radio" value="creator" />
فقط خودم (ایجادکننده)
</label>
<label>
<input v-model="editPolicy" type="radio" value="assignees" />
همهٔ مسئولان
</label>
</div>
<small class="hint">
{{
editPolicy === 'assignees'
? 'مسئولان هم می‌توانند این فعالیت را ویرایش یا حذف کنند.'
: 'فقط ایجادکننده می‌تواند این فعالیت را ویرایش یا حذف کند.'
}}
</small>
</div>
</div>
<div class="card">
<div class="field">
<div class="label-row">