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') @Get('users')
listUsers(@Query('q') q?: string) { listUsers(@Query('q') q?: string, @Query('owners') owners?: string) {
return this.platform.listUsers(q); return this.platform.listUsers(q, owners === '1' || owners === 'true');
} }
@Get('users/:id') @Get('users/:id')
+34 -20
View File
@@ -139,6 +139,7 @@ export class PlatformService {
email: true, email: true,
mobile: true, mobile: true,
isActive: true, isActive: true,
billingAccount: true,
}, },
}, },
}, },
@@ -149,16 +150,24 @@ export class PlatformService {
if (!row) throw new NotFoundException('محیط پیدا نشد'); if (!row) throw new NotFoundException('محیط پیدا نشد');
return { return {
...this.orgToDto(row), ...this.orgToDto(row),
members: row.memberships.map((m) => ({ members: row.memberships.map((m) => {
membershipId: m.id, const isBillingOwner = m.role === 'owner';
userId: m.user.id, return {
fullName: m.user.fullName, membershipId: m.id,
email: m.user.email, userId: m.user.id,
mobile: m.user.mobile, fullName: m.user.fullName,
role: m.role, email: m.user.email,
isActive: m.isActive, mobile: m.user.mobile,
userIsActive: m.user.isActive, 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 }); return this.updateOrganization(id, { isActive });
} }
async listUsers(query?: string): Promise<PlatformUserDto[]> { async listUsers(query?: string, ownersOnly = false): Promise<PlatformUserDto[]> {
const q = query?.trim(); const q = query?.trim();
const rows = await this.prisma.user.findMany({ const rows = await this.prisma.user.findMany({
where: q where: {
? { ...(q
OR: [ ? {
{ fullName: { contains: q, mode: 'insensitive' } }, OR: [
{ email: { contains: q, mode: 'insensitive' } }, { fullName: { contains: q, mode: 'insensitive' } },
{ mobile: { contains: q } }, { email: { contains: q, mode: 'insensitive' } },
], { mobile: { contains: q } },
} ],
: undefined, }
: {}),
...(ownersOnly
? { memberships: { some: { role: 'owner', isActive: true } } }
: {}),
},
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 300, take: 300,
include: { 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 { onMounted, reactive, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import type { import type {
ApplyBillingPlanRequest,
BillingPlanCode,
CreatePlatformOrganizationRequest, CreatePlatformOrganizationRequest,
OrgModuleKey, OrgModuleKey,
PlatformOrganizationDto, PlatformOrganizationDto,
PlatformUserDto,
UpdatePlatformOrganizationRequest, UpdatePlatformOrganizationRequest,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { import {
BILLING_PLAN_LABELS,
ORG_MODULE_GROUPS, ORG_MODULE_GROUPS,
ORG_MODULE_LABELS, ORG_MODULE_LABELS,
USER_ROLE_LABELS, USER_ROLE_LABELS,
@@ -18,6 +22,7 @@ import {
import { platformApi } from '@/lib/platform-api'; import { platformApi } from '@/lib/platform-api';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields'; import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import AdmBillingPanel from './AdmBillingPanel.vue';
const query = ref(''); const query = ref('');
const loading = ref(false); 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(() => { onMounted(() => {
void load(); void load();
}); });
@@ -443,37 +472,46 @@ onMounted(() => {
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p> <p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
<ul v-else> <ul v-else>
<li v-for="member in detail.members" :key="member.membershipId"> <li v-for="member in detail.members" :key="member.membershipId">
<div> <div class="member-head">
<strong>{{ member.fullName }}</strong> <div>
<span> <strong>{{ member.fullName }}</strong>
{{ member.email }} <span>
· حساب {{ member.userIsActive ? 'فعال' : 'غیرفعال' }} {{ member.email }}
</span> · حساب {{ member.userIsActive ? 'فعال' : 'غیرفعال' }}
</div> </span>
<div class="member-actions"> </div>
<select <div class="member-actions">
:value="member.role" <select
:disabled="busyId === `role-${member.membershipId}`" :value="member.role"
@change=" :disabled="busyId === `role-${member.membershipId}`"
setMembershipRole( @change="
member.membershipId, setMembershipRole(
($event.target as HTMLSelectElement).value as UserRole, 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 v-for="role in USER_ROLES" :key="role" :value="role">
</option> {{ USER_ROLE_LABELS[role] }}
</select> </option>
<button </select>
class="btn btn-ghost" <button
type="button" class="btn btn-ghost"
:disabled="busyId === member.membershipId" type="button"
@click="setMembershipActive(member.membershipId, !member.isActive)" :disabled="busyId === member.membershipId"
> @click="setMembershipActive(member.membershipId, !member.isActive)"
{{ member.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }} >
</button> {{ member.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
</button>
</div>
</div> </div>
<AdmBillingPanel
v-if="member.role === 'owner'"
:user-id="member.userId"
:billing="member.billing"
:busy-id="busyId"
@apply="applyPlan(member.userId, $event)"
/>
</li> </li>
</ul> </ul>
</div> </div>
@@ -707,6 +745,11 @@ label span {
} }
.detail li { .detail li {
display: grid;
gap: 0.75rem;
}
.member-head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 0.8rem; gap: 0.8rem;
@@ -726,6 +769,10 @@ label span {
align-items: center; align-items: center;
} }
:deep(.billing) {
margin-bottom: 0;
}
@media (max-width: 720px) { @media (max-width: 720px) {
.grid, .grid,
.edit-panel { .edit-panel {
+113 -46
View File
@@ -8,15 +8,16 @@ import type {
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { import {
BILLING_PLAN_CODES,
BILLING_PLAN_LABELS, BILLING_PLAN_LABELS,
USER_ROLE_LABELS, USER_ROLE_LABELS,
USER_ROLES, USER_ROLES,
} from '@mento/shared'; } from '@mento/shared';
import { platformApi } from '@/lib/platform-api'; import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import AdmBillingPanel from './AdmBillingPanel.vue';
const query = ref(''); const query = ref('');
const ownersOnly = ref(false);
const loading = ref(false); const loading = ref(false);
const error = ref(''); const error = ref('');
const success = ref(''); const success = ref('');
@@ -40,7 +41,10 @@ async function load() {
error.value = ''; error.value = '';
try { try {
const { data } = await platformApi.get<PlatformUserDto[]>('/platform/users', { 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; items.value = data;
} catch (e) { } 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(() => { onMounted(() => {
void load(); void load();
}); });
@@ -159,22 +169,49 @@ onMounted(() => {
<div class="page"> <div class="page">
<header class="head"> <header class="head">
<p class="lead">اشتراک مالک، نقش عضویت و ریست رمز.</p> <p class="lead">اشتراک مالک، نقش عضویت و ریست رمز.</p>
<form class="search" @submit.prevent="load"> <div class="head-actions">
<input v-model="query" type="search" placeholder="نام، ایمیل یا موبایل" /> <div class="seg" role="group" aria-label="فیلتر مالکان">
<button class="btn" type="submit" :disabled="loading">جستجو</button> <button
</form> 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> </header>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p> <p v-else-if="success" class="ok">{{ success }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</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"> <div v-else class="list">
<article v-for="user in items" :key="user.id" class="row"> <article v-for="user in items" :key="user.id" class="row">
<button type="button" class="row-main" @click="toggleOpen(user.id)"> <button type="button" class="row-main" @click="toggleOpen(user.id)">
<div> <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> <span>{{ user.email }}{{ user.mobile ? ` · ${user.mobile}` : '' }}</span>
</div> </div>
<div class="meta"> <div class="meta">
@@ -227,28 +264,13 @@ onMounted(() => {
</form> </form>
<div v-if="openId === user.id" class="detail"> <div v-if="openId === user.id" class="detail">
<div v-if="user.isBillingOwner" class="billing"> <AdmBillingPanel
<strong>اشتراک مالک</strong> v-if="user.isBillingOwner"
<p v-if="user.billing" class="muted"> :user-id="user.id"
{{ BILLING_PLAN_LABELS[user.billing.planCode] }} :billing="user.billing"
· {{ user.billing.status }} :busy-id="busyId"
· {{ toFaDigits(user.billing.daysRemaining) }} روز @apply="applyPlan(user.id, $event)"
· سقف محیط {{ 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>
<p v-else class="muted billing-skip"> <p v-else class="muted billing-skip">
این کاربر عضو زیرمجموعه است و اشتراک جدا ندارد؛ اشتراک روی مالک محیط است. این کاربر عضو زیرمجموعه است و اشتراک جدا ندارد؛ اشتراک روی مالک محیط است.
</p> </p>
@@ -310,6 +332,52 @@ onMounted(() => {
align-items: center; 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, .lead,
.head p, .head p,
.muted { .muted {
@@ -471,22 +539,6 @@ onMounted(() => {
font-size: 0.8rem; 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 { .billing-skip {
margin: 0 0 0.75rem; margin: 0 0 0.75rem;
} }
@@ -496,4 +548,19 @@ onMounted(() => {
gap: 0.45rem; gap: 0.45rem;
align-items: center; align-items: center;
} }
@media (max-width: 720px) {
.search {
width: 100%;
}
.search input {
flex: 1;
min-width: 0;
}
.row {
grid-template-columns: 1fr;
}
}
</style> </style>
+2
View File
@@ -2783,6 +2783,8 @@ export interface PlatformOrgMemberDto {
role: UserRole; role: UserRole;
isActive: boolean; isActive: boolean;
userIsActive: boolean; userIsActive: boolean;
isBillingOwner?: boolean;
billing?: BillingAccountDto | null;
} }
export interface PlatformOrganizationDto { export interface PlatformOrganizationDto {