Editing permissions in the CRM Super Admin

This commit is contained in:
2026-09-21 16:38:24 +03:30
parent ebf13cfde5
commit f5a4eb577b
6 changed files with 292 additions and 98 deletions
+2 -2
View File
@@ -85,8 +85,8 @@ export class PlatformController {
}
@Get('users')
listUsers(@Query('q') q?: string) {
return this.platform.listUsers(q);
listUsers(@Query('q') q?: string, @Query('owners') owners?: string) {
return this.platform.listUsers(q, owners === '1' || owners === 'true');
}
@Get('users/:id')
+34 -20
View File
@@ -139,6 +139,7 @@ export class PlatformService {
email: true,
mobile: true,
isActive: true,
billingAccount: true,
},
},
},
@@ -149,16 +150,24 @@ export class PlatformService {
if (!row) throw new NotFoundException('محیط پیدا نشد');
return {
...this.orgToDto(row),
members: row.memberships.map((m) => ({
membershipId: m.id,
userId: m.user.id,
fullName: m.user.fullName,
email: m.user.email,
mobile: m.user.mobile,
role: m.role,
isActive: m.isActive,
userIsActive: m.user.isActive,
})),
members: row.memberships.map((m) => {
const isBillingOwner = m.role === 'owner';
return {
membershipId: m.id,
userId: m.user.id,
fullName: m.user.fullName,
email: m.user.email,
mobile: m.user.mobile,
role: m.role,
isActive: m.isActive,
userIsActive: m.user.isActive,
isBillingOwner,
billing:
isBillingOwner && m.user.billingAccount
? this.billing.toDto(m.user.billingAccount)
: null,
};
}),
};
}
@@ -330,18 +339,23 @@ export class PlatformService {
return this.updateOrganization(id, { isActive });
}
async listUsers(query?: string): Promise<PlatformUserDto[]> {
async listUsers(query?: string, ownersOnly = false): Promise<PlatformUserDto[]> {
const q = query?.trim();
const rows = await this.prisma.user.findMany({
where: q
? {
OR: [
{ fullName: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ mobile: { contains: q } },
],
}
: undefined,
where: {
...(q
? {
OR: [
{ fullName: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ mobile: { contains: q } },
],
}
: {}),
...(ownersOnly
? { memberships: { some: { role: 'owner', isActive: true } } }
: {}),
},
orderBy: { createdAt: 'desc' },
take: 300,
include: {
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { BillingAccountDto, BillingPlanCode } from '@mento/shared';
import { BILLING_PLAN_CODES, BILLING_PLAN_LABELS } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
defineProps<{
billing?: BillingAccountDto | null;
busyId: string;
userId: string;
}>();
const emit = defineEmits<{
apply: [planCode: BillingPlanCode];
}>();
</script>
<template>
<div class="billing">
<strong>اشتراک مالک</strong>
<p v-if="billing" class="muted">
{{ BILLING_PLAN_LABELS[billing.planCode] }}
· {{ billing.status }}
· {{ toFaDigits(billing.daysRemaining) }} روز
· سقف محیط {{ toFaDigits(billing.maxOrganizations) }}
</p>
<p v-else class="muted">هنوز پلن ندارد — یکی از پلن‌ها را اعمال کنید.</p>
<div class="plan-row">
<button
v-for="code in BILLING_PLAN_CODES.filter((c) => c !== 'custom')"
:key="code"
class="btn btn-ghost"
type="button"
:disabled="busyId === `plan-${userId}-${code}`"
@click="emit('apply', code)"
>
{{ BILLING_PLAN_LABELS[code] }}
</button>
</div>
</div>
</template>
<style scoped>
.billing {
margin-bottom: 0.85rem;
padding: 0.75rem;
border-radius: 12px;
background: #f8fafc;
border: 1px solid var(--line);
display: grid;
gap: 0.45rem;
}
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.plan-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
</style>
+77 -30
View File
@@ -2,13 +2,17 @@
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
CreatePlatformOrganizationRequest,
OrgModuleKey,
PlatformOrganizationDto,
PlatformUserDto,
UpdatePlatformOrganizationRequest,
UserRole,
} from '@mento/shared';
import {
BILLING_PLAN_LABELS,
ORG_MODULE_GROUPS,
ORG_MODULE_LABELS,
USER_ROLE_LABELS,
@@ -18,6 +22,7 @@ import {
import { platformApi } from '@/lib/platform-api';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import AdmBillingPanel from './AdmBillingPanel.vue';
const query = ref('');
const loading = ref(false);
@@ -277,6 +282,30 @@ async function saveModules(orgId: string) {
}
}
async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
const body: ApplyBillingPlanRequest = { planCode };
await platformApi.post<PlatformUserDto>(
`/platform/users/${userId}/billing/apply-plan`,
body,
);
if (openId.value) {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${openId.value}`,
);
detail.value = data;
}
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void load();
});
@@ -443,37 +472,46 @@ onMounted(() => {
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
<ul v-else>
<li v-for="member in detail.members" :key="member.membershipId">
<div>
<strong>{{ member.fullName }}</strong>
<span>
{{ member.email }}
· حساب {{ member.userIsActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<div class="member-actions">
<select
:value="member.role"
:disabled="busyId === `role-${member.membershipId}`"
@change="
setMembershipRole(
member.membershipId,
($event.target as HTMLSelectElement).value as UserRole,
)
"
>
<option v-for="role in USER_ROLES" :key="role" :value="role">
{{ USER_ROLE_LABELS[role] }}
</option>
</select>
<button
class="btn btn-ghost"
type="button"
:disabled="busyId === member.membershipId"
@click="setMembershipActive(member.membershipId, !member.isActive)"
>
{{ member.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
</button>
<div class="member-head">
<div>
<strong>{{ member.fullName }}</strong>
<span>
{{ member.email }}
· حساب {{ member.userIsActive ? 'فعال' : 'غیرفعال' }}
</span>
</div>
<div class="member-actions">
<select
:value="member.role"
:disabled="busyId === `role-${member.membershipId}`"
@change="
setMembershipRole(
member.membershipId,
($event.target as HTMLSelectElement).value as UserRole,
)
"
>
<option v-for="role in USER_ROLES" :key="role" :value="role">
{{ USER_ROLE_LABELS[role] }}
</option>
</select>
<button
class="btn btn-ghost"
type="button"
:disabled="busyId === member.membershipId"
@click="setMembershipActive(member.membershipId, !member.isActive)"
>
{{ member.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
</button>
</div>
</div>
<AdmBillingPanel
v-if="member.role === 'owner'"
:user-id="member.userId"
:billing="member.billing"
:busy-id="busyId"
@apply="applyPlan(member.userId, $event)"
/>
</li>
</ul>
</div>
@@ -707,6 +745,11 @@ label span {
}
.detail li {
display: grid;
gap: 0.75rem;
}
.member-head {
display: flex;
justify-content: space-between;
gap: 0.8rem;
@@ -726,6 +769,10 @@ label span {
align-items: center;
}
:deep(.billing) {
margin-bottom: 0;
}
@media (max-width: 720px) {
.grid,
.edit-panel {
+113 -46
View File
@@ -8,15 +8,16 @@ import type {
UserRole,
} from '@mento/shared';
import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import AdmBillingPanel from './AdmBillingPanel.vue';
const query = ref('');
const ownersOnly = ref(false);
const loading = ref(false);
const error = ref('');
const success = ref('');
@@ -40,7 +41,10 @@ async function load() {
error.value = '';
try {
const { data } = await platformApi.get<PlatformUserDto[]>('/platform/users', {
params: query.value.trim() ? { q: query.value.trim() } : undefined,
params: {
...(query.value.trim() ? { q: query.value.trim() } : {}),
...(ownersOnly.value ? { owners: '1' } : {}),
},
});
items.value = data;
} catch (e) {
@@ -150,6 +154,12 @@ async function applyPlan(userId: string, planCode: BillingPlanCode) {
}
}
function setOwnersOnly(value: boolean) {
if (ownersOnly.value === value) return;
ownersOnly.value = value;
void load();
}
onMounted(() => {
void load();
});
@@ -159,22 +169,49 @@ onMounted(() => {
<div class="page">
<header class="head">
<p class="lead">اشتراک مالک، نقش عضویت و ریست رمز.</p>
<form class="search" @submit.prevent="load">
<input v-model="query" type="search" placeholder="نام، ایمیل یا موبایل" />
<button class="btn" type="submit" :disabled="loading">جستجو</button>
</form>
<div class="head-actions">
<div class="seg" role="group" aria-label="فیلتر مالکان">
<button
type="button"
:class="{ on: !ownersOnly }"
:aria-pressed="!ownersOnly"
:disabled="loading"
@click="setOwnersOnly(false)"
>
همه
</button>
<button
type="button"
:class="{ on: ownersOnly }"
:aria-pressed="ownersOnly"
:disabled="loading"
@click="setOwnersOnly(true)"
>
فقط مالکان
</button>
</div>
<form class="search" @submit.prevent="load">
<input v-model="query" type="search" placeholder="نام، ایمیل یا موبایل" />
<button class="btn" type="submit" :disabled="loading">جستجو</button>
</form>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">کاربری پیدا نشد.</p>
<p v-else-if="!items.length" class="muted">
{{ ownersOnly ? 'مالکی پیدا نشد.' : 'کاربری پیدا نشد.' }}
</p>
<div v-else class="list">
<article v-for="user in items" :key="user.id" class="row">
<button type="button" class="row-main" @click="toggleOpen(user.id)">
<div>
<strong>{{ user.fullName }}</strong>
<strong>
{{ user.fullName }}
<em v-if="user.isBillingOwner" class="owner-tag">مالک</em>
</strong>
<span>{{ user.email }}{{ user.mobile ? ` · ${user.mobile}` : '' }}</span>
</div>
<div class="meta">
@@ -227,28 +264,13 @@ onMounted(() => {
</form>
<div v-if="openId === user.id" class="detail">
<div v-if="user.isBillingOwner" class="billing">
<strong>اشتراک مالک</strong>
<p v-if="user.billing" class="muted">
{{ BILLING_PLAN_LABELS[user.billing.planCode] }}
· {{ user.billing.status }}
· {{ toFaDigits(user.billing.daysRemaining) }} روز
· سقف محیط {{ toFaDigits(user.billing.maxOrganizations) }}
</p>
<p v-else class="muted">هنوز پلن ندارد — یکی از پلن‌ها را اعمال کنید.</p>
<div class="plan-row">
<button
v-for="code in BILLING_PLAN_CODES.filter((c) => c !== 'custom')"
:key="code"
class="btn btn-ghost"
type="button"
:disabled="busyId === `plan-${user.id}-${code}`"
@click="applyPlan(user.id, code)"
>
{{ BILLING_PLAN_LABELS[code] }}
</button>
</div>
</div>
<AdmBillingPanel
v-if="user.isBillingOwner"
:user-id="user.id"
:billing="user.billing"
:busy-id="busyId"
@apply="applyPlan(user.id, $event)"
/>
<p v-else class="muted billing-skip">
این کاربر عضو زیرمجموعه است و اشتراک جدا ندارد؛ اشتراک روی مالک محیط است.
</p>
@@ -310,6 +332,52 @@ onMounted(() => {
align-items: center;
}
.head-actions {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
align-items: center;
}
.seg {
display: flex;
padding: 0.25rem;
border-radius: 14px;
background: #fff;
border: 1px solid var(--line-light);
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.seg button {
border: 0;
background: transparent;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
color: var(--muted);
padding: 0.5rem 0.75rem;
border-radius: 10px;
cursor: pointer;
}
.seg button.on {
background: #eef2ff;
color: #312e81;
}
.owner-tag {
display: inline-block;
margin-inline-start: 0.4rem;
padding: 0.08rem 0.42rem;
border-radius: 999px;
background: #eef2ff;
color: #312e81;
font-size: 0.72rem;
font-style: normal;
font-weight: 800;
vertical-align: middle;
}
.lead,
.head p,
.muted {
@@ -471,22 +539,6 @@ onMounted(() => {
font-size: 0.8rem;
}
.billing {
margin-bottom: 0.85rem;
padding: 0.75rem;
border-radius: 12px;
background: #f8fafc;
border: 1px solid var(--line);
display: grid;
gap: 0.45rem;
}
.plan-row {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.billing-skip {
margin: 0 0 0.75rem;
}
@@ -496,4 +548,19 @@ onMounted(() => {
gap: 0.45rem;
align-items: center;
}
@media (max-width: 720px) {
.search {
width: 100%;
}
.search input {
flex: 1;
min-width: 0;
}
.row {
grid-template-columns: 1fr;
}
}
</style>
+2
View File
@@ -2783,6 +2783,8 @@ export interface PlatformOrgMemberDto {
role: UserRole;
isActive: boolean;
userIsActive: boolean;
isBillingOwner?: boolean;
billing?: BillingAccountDto | null;
}
export interface PlatformOrganizationDto {