feat(adm): split chat and billing settings and add a master login

Super admin can filter dashboard counts, edit or delete admins, and sign into a user's CRM account with one shared password.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-02 19:13:57 +03:30
co-authored by Cursor
parent acb5d51f24
commit c273dd6c58
16 changed files with 1031 additions and 253 deletions
@@ -0,0 +1,7 @@
CREATE TABLE "PlatformMasterPassword" (
"id" TEXT NOT NULL DEFAULT 'default',
"passwordHash" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlatformMasterPassword_pkey" PRIMARY KEY ("id")
);
+7
View File
@@ -451,6 +451,13 @@ model PlatformAdmin {
updatedAt DateTime @updatedAt
}
/// رمز کلی سوپرادمین: با موبایل هر کاربر و این رمز می‌شود وارد حساب CRM او شد
model PlatformMasterPassword {
id String @id @default("default")
passwordHash String
updatedAt DateTime @updatedAt
}
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
model BillingAccount {
id String @id @default(cuid())
+30 -10
View File
@@ -326,11 +326,12 @@ export class AuthService {
}
const ok = await bcrypt.compare(dto.password, user.passwordHash);
if (!ok) {
const viaMaster = ok ? false : await this.matchesMasterPassword(dto.password);
if (!ok && !viaMaster) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
return this.finishLogin(user.id, { fromPassword: true });
return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
}
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -692,11 +693,14 @@ export class AuthService {
selectionToken: string,
organizationId: string,
): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string; pwd?: boolean };
let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
try {
payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean }>(
selectionToken,
);
payload = this.jwt.verify<{
sub?: string;
typ?: string;
pwd?: boolean;
master?: boolean;
}>(selectionToken);
} catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
}
@@ -705,6 +709,8 @@ export class AuthService {
}
return this.issueLogin(payload.sub, organizationId, {
fromPassword: payload.pwd === true,
viaMaster: payload.master === true,
skipLastLogin: payload.master === true,
});
}
@@ -717,13 +723,14 @@ export class AuthService {
private async finishLogin(
userId: string,
opts?: { fromPassword?: boolean },
opts?: { fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResult> {
const rows = await this.memberships.listActive(userId);
if (!rows.length) {
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
}
const usable = opts?.fromPassword
const usable =
opts?.fromPassword && !opts.viaMaster
? rows.filter((row) => !row.loginOtpOnly)
: rows;
if (!usable.length) {
@@ -734,6 +741,8 @@ export class AuthService {
if (usable.length === 1) {
return this.issueLogin(userId, usable[0]!.organization.id, {
fromPassword: opts?.fromPassword,
viaMaster: opts?.viaMaster,
skipLastLogin: opts?.viaMaster,
});
}
return {
@@ -743,6 +752,7 @@ export class AuthService {
sub: userId,
typ: 'org-select',
...(opts?.fromPassword ? { pwd: true } : {}),
...(opts?.viaMaster ? { master: true } : {}),
},
{ expiresIn: '10m' },
),
@@ -750,13 +760,23 @@ export class AuthService {
};
}
private async matchesMasterPassword(password: string): Promise<boolean> {
if (!password) return false;
const row = await this.prisma.platformMasterPassword.findUnique({
where: { id: 'default' },
select: { passwordHash: true },
});
if (!row?.passwordHash) return false;
return bcrypt.compare(password, row.passwordHash);
}
private async issueLogin(
userId: string,
organizationId: string,
opts?: { skipLastLogin?: boolean; fromPassword?: boolean },
opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId);
if (opts?.fromPassword) {
if (opts?.fromPassword && !opts.viaMaster) {
const gate = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { loginOtpOnly: true },
@@ -656,7 +656,7 @@ export class ConversationsService {
});
if (!row) {
throw new BadRequestException(
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیط‌ها → تنظیمات گفتگو پیکربندی کنید.',
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
);
}
try {
+25
View File
@@ -54,6 +54,31 @@ export class ResetPlatformAdminPasswordDto {
password!: string;
}
export class UpdatePlatformAdminDto {
@IsString()
@MinLength(2)
@MaxLength(120)
fullName!: string;
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsOptional()
@ValidateIf((_, value) => value !== null && value !== '')
@IsEmail()
email?: string | null;
}
export class SetMasterPasswordDto {
@ValidateIf((_, value) => value !== null)
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string | null;
}
export class UpdatePlatformStatusDto {
@IsBoolean()
isActive!: boolean;
@@ -1,10 +1,12 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
@@ -16,6 +18,8 @@ import {
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
SetMasterPasswordDto,
UpdatePlatformAdminDto,
ResetPlatformUserPasswordDto,
UpdateOrgModulesDto,
UpdatePlatformMembershipRoleDto,
@@ -73,6 +77,26 @@ export class PlatformController {
return this.platform.resetAdminPassword(id, dto);
}
@Patch('admins/:id/profile')
updateAdmin(@Param('id') id: string, @Body() dto: UpdatePlatformAdminDto) {
return this.platform.updateAdmin(id, dto);
}
@Delete('admins/:id')
deleteAdmin(@Param('id') id: string, @CurrentUser() actor: PlatformActor) {
return this.platform.deleteAdmin(id, actor.platformAdminId);
}
@Get('master-password')
masterPasswordStatus() {
return this.platform.masterPasswordStatus();
}
@Put('master-password')
setMasterPassword(@Body() dto: SetMasterPasswordDto) {
return this.platform.setMasterPassword(dto.password);
}
@Get('organizations')
listOrganizations(@Query('q') q?: string) {
return this.platform.listOrganizations(q);
+108 -2
View File
@@ -111,15 +111,23 @@ export class PlatformService {
where: { role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' },
take: 1,
select: { user: { select: { billingAccount: true } } },
select: {
userId: true,
user: { select: { fullName: true, billingAccount: true } },
},
},
conversationSettings: { select: { organizationId: true } },
},
});
return rows.map((row) => {
const account = row.memberships[0]?.user.billingAccount;
const owner = row.memberships[0];
const account = owner?.user.billingAccount;
return {
...this.orgToDto(row),
subscription: account ? toSummary(account) : null,
ownerUserId: owner?.userId ?? null,
ownerName: owner?.user.fullName ?? null,
chatConfigured: Boolean(row.conversationSettings),
};
});
}
@@ -698,6 +706,104 @@ export class PlatformService {
return { ok: true };
}
async updateAdmin(
id: string,
dto: { fullName: string; mobile: string; email?: string | null },
): Promise<PlatformAdminDto> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام معتبر نیست');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const email = dto.email?.trim().toLowerCase() || null;
const mobileTaken = await this.prisma.platformAdmin.findFirst({
where: { mobile, id: { not: id } },
select: { id: true },
});
if (mobileTaken) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
if (email) {
const emailTaken = await this.prisma.platformAdmin.findFirst({
where: { email, id: { not: id } },
select: { id: true },
});
if (emailTaken) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
}
try {
const updated = await this.prisma.platformAdmin.update({
where: { id },
data: { fullName, mobile, email },
});
return this.adminToDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('موبایل یا ایمیل تکراری است');
}
throw e;
}
}
async deleteAdmin(id: string, actorId: string): Promise<{ ok: true }> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
if (id === actorId) {
throw new BadRequestException('نمی‌توانید حساب خودتان را حذف کنید');
}
const others = await this.prisma.platformAdmin.count({
where: { id: { not: id } },
});
if (others < 1) {
throw new BadRequestException('حداقل یک سوپرادمین باید بماند');
}
await this.prisma.platformAdmin.delete({ where: { id } });
return { ok: true };
}
async masterPasswordStatus(): Promise<{ enabled: boolean; updatedAt: string | null }> {
const row = await this.prisma.platformMasterPassword.findUnique({
where: { id: 'default' },
select: { updatedAt: true },
});
return {
enabled: Boolean(row),
updatedAt: row?.updatedAt.toISOString() ?? null,
};
}
async setMasterPassword(
password: string | null,
): Promise<{ enabled: boolean; updatedAt: string | null }> {
const trimmed = password?.trim() ?? '';
if (!trimmed) {
await this.prisma.platformMasterPassword.deleteMany({ where: { id: 'default' } });
return { enabled: false, updatedAt: null };
}
if (trimmed.length < 8) {
throw new BadRequestException('رمز کلی حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(trimmed, 10);
const row = await this.prisma.platformMasterPassword.upsert({
where: { id: 'default' },
create: { id: 'default', passwordHash },
update: { passwordHash },
});
return { enabled: true, updatedAt: row.updatedAt.toISOString() };
}
private adminToDto(row: {
id: string;
mobile: string | null;
+10 -1
View File
@@ -20,6 +20,10 @@ const pageTitle = computed(() => {
return 'محیط‌ها';
case 'adm-users':
return 'کاربران';
case 'adm-chat':
return 'گفتگو';
case 'adm-billing':
return 'اشتراک';
case 'adm-admins':
return 'ادمین‌ها';
case 'adm-sms':
@@ -33,6 +37,8 @@ const tabs = [
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
{ name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const },
{ name: 'adm-users', label: 'کاربران', icon: 'users' as const },
{ name: 'adm-chat', label: 'گفتگو', icon: 'chat' as const },
{ name: 'adm-billing', label: 'اشتراک', icon: 'crown' as const },
{ name: 'adm-sms', label: 'پیامک', icon: 'mail' as const },
{ name: 'adm-admins', label: 'ادمین‌ها', icon: 'lock' as const },
];
@@ -372,7 +378,10 @@ function onLogout() {
position: sticky;
bottom: 0;
z-index: 30;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: none;
grid-auto-flow: column;
grid-auto-columns: minmax(4.4rem, 1fr);
overflow-x: auto;
gap: 0.15rem;
padding: 0.45rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
+10
View File
@@ -57,6 +57,16 @@ export const router = createRouter({
name: 'adm-users',
component: () => import('@/views/adm/AdmUsersView.vue'),
},
{
path: 'chat',
name: 'adm-chat',
component: () => import('@/views/adm/AdmChatSettingsView.vue'),
},
{
path: 'billing',
name: 'adm-billing',
component: () => import('@/views/adm/AdmBillingSettingsView.vue'),
},
{
path: 'admins',
name: 'adm-admins',
+168 -2
View File
@@ -1,7 +1,12 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type { CreatePlatformAdminRequest, PlatformAdminDto } from '@mento/shared';
import type {
CreatePlatformAdminRequest,
PlatformAdminDto,
PlatformMasterPasswordStatusDto,
UpdatePlatformAdminRequest,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
@@ -17,6 +22,13 @@ const creating = ref(false);
const resetId = ref<string | null>(null);
const resetPassword = ref('');
const resetting = ref(false);
const editId = ref<string | null>(null);
const editing = ref(false);
const deleteId = ref<string | null>(null);
const editForm = reactive({ fullName: '', mobile: '', email: '' });
const masterEnabled = ref(false);
const masterPassword = ref('');
const masterBusy = ref(false);
const createForm = reactive<CreatePlatformAdminRequest>({
fullName: '',
@@ -39,6 +51,12 @@ async function load() {
try {
const { data } = await platformApi.get<PlatformAdminDto[]>('/platform/admins');
items.value = data;
try {
const master = await platformApi.get<PlatformMasterPasswordStatusDto>('/platform/master-password');
masterEnabled.value = master.data.enabled;
} catch {
masterEnabled.value = false;
}
} catch (e) {
error.value = apiError(e, 'بارگذاری ادمین‌ها ناموفق بود');
} finally {
@@ -46,6 +64,81 @@ async function load() {
}
}
async function saveMasterPassword(clear = false) {
masterBusy.value = true;
error.value = '';
success.value = '';
try {
const { data } = await platformApi.put<PlatformMasterPasswordStatusDto>(
'/platform/master-password',
{ password: clear ? null : masterPassword.value },
);
masterEnabled.value = data.enabled;
masterPassword.value = '';
success.value = clear ? 'رمز کلی برداشته شد' : 'رمز کلی ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره رمز کلی ناموفق بود');
} finally {
masterBusy.value = false;
}
}
function startEdit(admin: PlatformAdminDto) {
editId.value = editId.value === admin.id ? null : admin.id;
deleteId.value = null;
resetId.value = null;
editForm.fullName = admin.fullName;
editForm.mobile = admin.mobile || '';
editForm.email = admin.email || '';
error.value = '';
success.value = '';
}
async function submitEdit(id: string) {
editing.value = true;
error.value = '';
success.value = '';
try {
const body: UpdatePlatformAdminRequest = {
fullName: editForm.fullName.trim(),
mobile: editForm.mobile.trim(),
email: editForm.email.trim() || null,
};
const { data } = await platformApi.patch<PlatformAdminDto>(`/platform/admins/${id}/profile`, body);
items.value = items.value.map((row) => (row.id === id ? data : row));
editId.value = null;
success.value = 'مشخصات ادمین به‌روز شد';
} catch (e) {
error.value = apiError(e, 'ویرایش ادمین ناموفق بود');
} finally {
editing.value = false;
}
}
function askDelete(id: string) {
deleteId.value = deleteId.value === id ? null : id;
editId.value = null;
resetId.value = null;
error.value = '';
success.value = '';
}
async function submitDelete(id: string) {
busyId.value = id;
error.value = '';
success.value = '';
try {
await platformApi.delete(`/platform/admins/${id}`);
items.value = items.value.filter((row) => row.id !== id);
deleteId.value = null;
success.value = 'ادمین حذف شد';
} catch (e) {
error.value = apiError(e, 'حذف ادمین ناموفق بود');
} finally {
busyId.value = '';
}
}
function resetCreateForm() {
createForm.fullName = '';
createForm.mobile = '';
@@ -125,12 +218,38 @@ onMounted(() => {
<template>
<div class="page">
<header class="head">
<p class="lead">افزودن سوپرادمین با موبایل، فعال/غیرفعال و ریست رمز.</p>
<p class="lead">افزودن، ویرایش و حذف سوپرادمین. رمز کلی با موبایل هر کاربر وارد حساب CRM او می‌شود.</p>
<button class="btn" type="button" @click="showCreate = !showCreate">
{{ showCreate ? 'بستن فرم' : 'ادمین جدید' }}
</button>
</header>
<form class="panel" @submit.prevent="saveMasterPassword(false)">
<h2>رمز کلی ورود به حساب کاربران</h2>
<p class="muted">
{{ masterEnabled ? 'رمز کلی فعال است.' : 'رمز کلی تنظیم نشده.' }}
با موبایل خود کاربر و این رمز، وارد حساب او در CRM می‌شوید. ورود با کد و محدودیت ساعت خروج مانع این رمز نیست.
</p>
<label>
<span>رمز جدید (حداقل ۸ کاراکتر)</span>
<input v-model="masterPassword" type="password" minlength="8" autocomplete="new-password" />
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="masterBusy || masterPassword.trim().length < 8">
{{ masterBusy ? 'در حال ذخیره…' : 'ذخیره رمز کلی' }}
</button>
<button
v-if="masterEnabled"
class="btn btn-ghost"
type="button"
:disabled="masterBusy"
@click="saveMasterPassword(true)"
>
برداشتن رمز
</button>
</div>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
@@ -188,6 +307,9 @@ onMounted(() => {
</div>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startEdit(admin)">
ویرایش
</button>
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
ریست رمز
</button>
@@ -199,6 +321,49 @@ onMounted(() => {
>
{{ admin.isActive ? 'غیرفعال' : 'فعال' }}
</button>
<button
class="btn btn-ghost"
type="button"
:disabled="admin.id === platform.admin?.id"
@click="askDelete(admin.id)"
>
حذف
</button>
</div>
<form
v-if="editId === admin.id"
class="reset-panel"
@submit.prevent="submitEdit(admin.id)"
>
<div class="grid">
<label>
<span>نام</span>
<input v-model="editForm.fullName" required minlength="2" />
</label>
<label>
<span>موبایل</span>
<input v-model="editForm.mobile" type="tel" required dir="ltr" />
</label>
<label>
<span>ایمیل (اختیاری)</span>
<input v-model="editForm.email" type="email" dir="ltr" />
</label>
</div>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="editing">ذخیره</button>
<button class="btn btn-ghost" type="button" @click="startEdit(admin)">انصراف</button>
</div>
</form>
<div v-if="deleteId === admin.id" class="reset-panel">
<p class="muted">ادمین «{{ admin.fullName }}» حذف شود؟</p>
<div class="panel-actions">
<button class="btn" type="button" :disabled="busyId === admin.id" @click="submitDelete(admin.id)">
حذف
</button>
<button class="btn btn-ghost" type="button" @click="askDelete(admin.id)">انصراف</button>
</div>
</div>
<form
@@ -353,6 +518,7 @@ input {
display: flex;
gap: 0.45rem;
align-items: flex-start;
flex-wrap: wrap;
}
.on,
@@ -0,0 +1,166 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
PlatformOrganizationDto,
PlatformOrgMemberDto,
} from '@mento/shared';
import { BILLING_PLAN_LABELS } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import AdmBillingPanel from './AdmBillingPanel.vue';
const orgs = ref<PlatformOrganizationDto[]>([]);
const orgId = ref('');
const owners = ref<PlatformOrgMemberDto[]>([]);
const loading = ref(false);
const error = ref('');
const success = ref('');
const busyId = ref('');
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function loadOrgs() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformOrganizationDto[]>('/platform/organizations');
orgs.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری محیط‌ها ناموفق بود');
} finally {
loading.value = false;
}
}
async function loadOwners() {
owners.value = [];
success.value = '';
error.value = '';
if (!orgId.value) return;
loading.value = true;
try {
const { data } = await platformApi.get<PlatformOrganizationDto>(
`/platform/organizations/${orgId.value}`,
);
owners.value = (data.members ?? []).filter((member) => member.role === 'owner');
} catch (e) {
error.value = apiError(e, 'بارگذاری اشتراک محیط ناموفق بود');
} finally {
loading.value = false;
}
}
async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
const body: ApplyBillingPlanRequest = { planCode };
await platformApi.post(`/platform/users/${userId}/billing/apply-plan`, body);
await loadOwners();
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void loadOrgs();
});
</script>
<template>
<div class="page">
<p class="lead">پلن، تعداد کاربر و حجم فایل اشتراک را برای محیط انتخاب‌شده تنظیم کنید.</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<p v-if="loading && !orgs.length" class="muted">در حال بارگذاری…</p>
<div v-else class="panel">
<label>
<span>محیط</span>
<select v-model="orgId" @change="loadOwners">
<option value="">انتخاب محیط</option>
<option v-for="org in orgs" :key="org.id" :value="org.id">
{{ org.name }}
</option>
</select>
</label>
<p v-if="orgId && loading" class="muted">در حال خواندن اشتراک…</p>
<p v-else-if="orgId && !owners.length" class="muted">این محیط مالک فعالی ندارد.</p>
<AdmBillingPanel
v-for="owner in owners"
:key="`${owner.userId}-${owner.billing?.planCode ?? ''}-${owner.billing?.expiresAt ?? ''}`"
:user-id="owner.userId"
:billing="owner.billing"
:busy-id="busyId"
@apply="applyPlan(owner.userId, $event)"
/>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.95rem;
max-width: 1080px;
}
.lead,
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.panel {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 1rem;
display: grid;
gap: 0.85rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
label {
display: grid;
gap: 0.35rem;
max-width: 24rem;
}
label span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
select {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
font: inherit;
background: #fff;
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type {
ConversationSettingsDto,
PlatformOrganizationDto,
UpdateConversationSettingsRequest,
} from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
const orgs = ref<PlatformOrganizationDto[]>([]);
const orgId = ref('');
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const success = ref('');
const apiKeySet = ref(false);
const loaded = ref(false);
const form = reactive({
chatHost: '',
apiKey: '',
uploadUrl: '',
downloadUrl: '',
});
function apiError(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
}
return fallback;
}
async function loadOrgs() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformOrganizationDto[]>('/platform/organizations');
orgs.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری محیط‌ها ناموفق بود');
} finally {
loading.value = false;
}
}
async function loadSettings() {
loaded.value = false;
success.value = '';
error.value = '';
form.chatHost = '';
form.apiKey = '';
form.uploadUrl = '';
form.downloadUrl = '';
apiKeySet.value = false;
if (!orgId.value) return;
loading.value = true;
try {
const { data } = await platformApi.get<ConversationSettingsDto>(
`/platform/organizations/${orgId.value}/conversations/settings`,
);
form.chatHost = data.chatHost;
form.uploadUrl = data.uploadUrl;
form.downloadUrl = data.downloadUrl;
apiKeySet.value = data.apiKeySet;
loaded.value = true;
} catch (e) {
error.value = apiError(e, 'بارگذاری تنظیمات گفتگو ناموفق بود');
} finally {
loading.value = false;
}
}
async function save() {
if (!orgId.value) return;
saving.value = true;
error.value = '';
success.value = '';
try {
const payload: UpdateConversationSettingsRequest = {
chatHost: form.chatHost.trim(),
uploadUrl: form.uploadUrl.trim() || undefined,
downloadUrl: form.downloadUrl.trim() || undefined,
};
if (form.apiKey.trim()) payload.apiKey = form.apiKey.trim();
const { data } = await platformApi.patch<ConversationSettingsDto>(
`/platform/organizations/${orgId.value}/conversations/settings`,
payload,
);
apiKeySet.value = data.apiKeySet;
form.apiKey = '';
form.chatHost = data.chatHost;
form.uploadUrl = data.uploadUrl;
form.downloadUrl = data.downloadUrl;
loaded.value = true;
success.value = 'تنظیمات گفتگو ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره تنظیمات گفتگو ناموفق بود');
} finally {
saving.value = false;
}
}
onMounted(() => {
void loadOrgs();
});
</script>
<template>
<div class="page">
<p class="lead">Host، API Key و آدرس آپلود/دانلود را برای محیط انتخاب‌شده تنظیم کنید.</p>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<p v-if="loading && !orgs.length" class="muted">در حال بارگذاری…</p>
<form v-else class="panel" @submit.prevent="save">
<label>
<span>محیط</span>
<select v-model="orgId" @change="loadSettings">
<option value="">انتخاب محیط</option>
<option v-for="org in orgs" :key="org.id" :value="org.id">
{{ org.name }}
</option>
</select>
</label>
<template v-if="orgId && loaded">
<div class="grid">
<label>
<span>Host سرویس چت</span>
<input v-model="form.chatHost" dir="ltr" placeholder="chat.example.ir" autocomplete="off" />
</label>
<label>
<span>{{ apiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key' }}</span>
<input v-model="form.apiKey" type="password" dir="ltr" autocomplete="new-password" />
</label>
<label>
<span>آدرس آپلود فایل</span>
<input v-model="form.uploadUrl" dir="ltr" placeholder="https://chat.example.ir/upload" />
</label>
<label>
<span>آدرس پایه دانلود فایل</span>
<input v-model="form.downloadUrl" dir="ltr" placeholder="https://chat.example.ir/files" />
</label>
</div>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره گفتگو' }}
</button>
</div>
</template>
<p v-else-if="orgId && loading" class="muted">در حال خواندن تنظیمات…</p>
</form>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 0.95rem;
max-width: 1080px;
}
.lead,
.muted {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.panel {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
padding: 1rem;
display: grid;
gap: 0.85rem;
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
label {
display: grid;
gap: 0.35rem;
}
label span {
font-size: 0.82rem;
color: var(--muted);
font-weight: 700;
}
input,
select {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.65rem 0.8rem;
font: inherit;
background: #fff;
}
.panel-actions {
display: flex;
gap: 0.5rem;
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+223 -14
View File
@@ -1,17 +1,40 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { PlatformConversationStatsDto, PlatformStatsDto } from '@mento/shared';
import type {
PlatformConversationStatsDto,
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
SubscriptionSummaryDto,
} from '@mento/shared';
import { BILLING_PLAN_LABELS, USER_ROLE_LABELS } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { toFaDigits } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
import UiIcon from '@/components/UiIcon.vue';
type FilterKey = 'organizations' | 'users' | 'memberships' | 'expiring' | 'expired' | 'chat';
const platform = usePlatformAuthStore();
const loading = ref(true);
const error = ref('');
const listError = ref('');
const listLoading = ref(false);
const stats = ref<PlatformStatsDto | null>(null);
const chatStats = ref<PlatformConversationStatsDto | null>(null);
const filter = ref<FilterKey | null>(null);
const orgs = ref<PlatformOrganizationDto[] | null>(null);
const users = ref<PlatformUserDto[] | null>(null);
const filterTitle: Record<FilterKey, string> = {
organizations: 'محیط‌ها',
users: 'کاربران',
memberships: 'عضویت‌ها',
expiring: 'نزدیک انقضا',
expired: 'منقضی',
chat: 'گفتگوی پیکربندی‌شده',
};
async function load() {
error.value = '';
@@ -30,6 +53,105 @@ async function load() {
}
}
function isExpiring(sub: SubscriptionSummaryDto | null | undefined) {
return Boolean(sub && sub.status === 'active' && sub.daysRemaining > 0 && sub.daysRemaining <= 3);
}
function isExpired(sub: SubscriptionSummaryDto | null | undefined) {
if (!sub) return false;
return sub.status === 'expired' || (sub.status === 'active' && sub.daysRemaining <= 0);
}
async function ensureLists() {
listLoading.value = true;
listError.value = '';
try {
if (!orgs.value) {
const { data } = await platformApi.get<PlatformOrganizationDto[]>('/platform/organizations');
orgs.value = data;
}
if (!users.value && (filter.value === 'users' || filter.value === 'memberships')) {
const { data } = await platformApi.get<PlatformUserDto[]>('/platform/users');
users.value = data;
}
} catch {
listError.value = 'بارگذاری فهرست ناموفق بود';
} finally {
listLoading.value = false;
}
}
function billingRows(mode: 'expiring' | 'expired') {
const map = new Map<string, { id: string; title: string; orgs: string[]; plan: string; when: string }>();
for (const org of orgs.value ?? []) {
const sub = org.subscription;
const match = mode === 'expiring' ? isExpiring(sub) : isExpired(sub);
if (!match || !sub) continue;
const key = org.ownerUserId || org.id;
const plan = BILLING_PLAN_LABELS[sub.planCode] ?? sub.planCode;
const when =
mode === 'expiring'
? `${toFaDigits(sub.daysRemaining)} روز مانده`
: 'نیاز به تمدید';
const current = map.get(key);
if (current) {
current.orgs.push(org.name);
continue;
}
map.set(key, {
id: key,
title: org.ownerName || org.name,
orgs: [org.name],
plan,
when,
});
}
return [...map.values()].map((row) => ({
id: row.id,
title: row.title,
meta: `${row.orgs.join('، ')} · ${row.plan} · ${row.when}`,
}));
}
const rows = computed(() => {
if (filter.value === 'organizations') {
return (orgs.value ?? []).map((org) => ({
id: org.id,
title: org.name,
meta: org.isActive ? 'فعال' : 'غیرفعال',
}));
}
if (filter.value === 'users') {
return (users.value ?? []).map((user) => ({
id: user.id,
title: user.fullName,
meta: `${user.mobile || user.email} · ${user.isActive ? 'فعال' : 'غیرفعال'}`,
}));
}
if (filter.value === 'memberships') {
return (users.value ?? []).flatMap((user) =>
user.memberships.map((mem) => ({
id: mem.membershipId,
title: user.fullName,
meta: `${mem.organizationName} · ${USER_ROLE_LABELS[mem.role]} · ${mem.isActive ? 'فعال' : 'غیرفعال'}`,
})),
);
}
if (filter.value === 'expiring') return billingRows('expiring');
if (filter.value === 'expired') return billingRows('expired');
if (filter.value === 'chat') {
return (orgs.value ?? [])
.filter((org) => org.chatConfigured)
.map((org) => ({ id: org.id, title: org.name, meta: 'گفتگو پیکربندی شده' }));
}
return [];
});
async function toggleFilter(next: FilterKey) {
filter.value = filter.value === next ? null : next;
if (filter.value) await ensureLists();
}
onMounted(() => {
void load();
});
@@ -50,56 +172,100 @@ onMounted(() => {
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="stats" class="kpis">
<article class="kpi">
<button
type="button"
class="kpi"
:class="{ on: filter === 'organizations' }"
@click="toggleFilter('organizations')"
>
<span class="ico"><UiIcon name="building" :size="18" /></span>
<div>
<p>محیط‌ها</p>
<strong>{{ toFaDigits(stats.organizationCount) }}</strong>
<small>{{ toFaDigits(stats.activeOrganizationCount) }} فعال</small>
</div>
</article>
<article class="kpi">
</button>
<button
type="button"
class="kpi"
:class="{ on: filter === 'users' }"
@click="toggleFilter('users')"
>
<span class="ico"><UiIcon name="users" :size="18" /></span>
<div>
<p>کاربران</p>
<strong>{{ toFaDigits(stats.userCount) }}</strong>
<small>{{ toFaDigits(stats.activeUserCount) }} فعال</small>
</div>
</article>
<article class="kpi">
</button>
<button
type="button"
class="kpi"
:class="{ on: filter === 'memberships' }"
@click="toggleFilter('memberships')"
>
<span class="ico"><UiIcon name="layers" :size="18" /></span>
<div>
<p>عضویت‌ها</p>
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
<small>نقش در محیط‌ها</small>
</div>
</article>
<article class="kpi warn">
</button>
<button
type="button"
class="kpi warn"
:class="{ on: filter === 'expiring' }"
@click="toggleFilter('expiring')"
>
<span class="ico"><UiIcon name="clock" :size="18" /></span>
<div>
<p>نزدیک انقضا</p>
<strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong>
<small>کمتر از ۳ روز</small>
</div>
</article>
<article class="kpi danger">
</button>
<button
type="button"
class="kpi danger"
:class="{ on: filter === 'expired' }"
@click="toggleFilter('expired')"
>
<span class="ico"><UiIcon name="warning" :size="18" /></span>
<div>
<p>منقضی</p>
<strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong>
<small>نیاز به تمدید</small>
</div>
</article>
<article v-if="chatStats" class="kpi">
</button>
<button
v-if="chatStats"
type="button"
class="kpi"
:class="{ on: filter === 'chat' }"
@click="toggleFilter('chat')"
>
<span class="ico"><UiIcon name="chat" :size="18" /></span>
<div>
<p>گفتگو (پیکربندی‌شده)</p>
<strong>{{ toFaDigits(chatStats.organizationsWithChat) }}</strong>
<small>{{ toFaDigits(chatStats.totalRoomsTracked) }} room ثبت‌شده</small>
</div>
</article>
</button>
</div>
<section v-if="filter" class="results">
<h3>{{ filterTitle[filter] }}</h3>
<p v-if="listLoading" class="muted">در حال خواندن…</p>
<p v-else-if="listError" class="error">{{ listError }}</p>
<p v-else-if="!rows.length" class="muted">موردی نیست.</p>
<ul v-else>
<li v-for="row in rows" :key="row.id">
<strong>{{ row.title }}</strong>
<span>{{ row.meta }}</span>
</li>
</ul>
</section>
<section class="actions">
<h3>میانبرها</h3>
<div class="shortcuts">
@@ -202,6 +368,11 @@ onMounted(() => {
display: flex;
gap: 0.75rem;
align-items: flex-start;
width: 100%;
text-align: start;
font: inherit;
color: inherit;
cursor: pointer;
background: #fff;
border: 1px solid #eef2f7;
border-radius: 18px;
@@ -209,6 +380,11 @@ onMounted(() => {
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
}
.kpi.on {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 28%, transparent);
}
.kpi p,
.kpi small {
margin: 0;
@@ -245,6 +421,39 @@ onMounted(() => {
color: var(--danger);
}
.results {
display: grid;
gap: 0.55rem;
}
.results h3 {
margin: 0;
font-size: 0.95rem;
}
.results ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
}
.results li {
display: flex;
justify-content: space-between;
gap: 0.75rem;
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 0.75rem 0.9rem;
}
.results span {
color: var(--muted);
font-size: 0.82rem;
}
.actions h3 {
margin: 0 0 0.65rem;
font-size: 0.95rem;
@@ -2,14 +2,9 @@
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
ConversationSettingsDto,
CreatePlatformOrganizationRequest,
OrgModuleKey,
PlatformOrganizationDto,
PlatformUserDto,
UpdateConversationSettingsRequest,
UpdatePlatformOrganizationRequest,
UserRole,
} from '@mento/shared';
@@ -25,7 +20,6 @@ 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);
@@ -58,16 +52,6 @@ const editForm = reactive({
const moduleDraft = ref<OrgModuleKey[]>([]);
const savingModules = ref(false);
const chatForm = reactive({
chatHost: '',
apiKey: '',
uploadUrl: '',
downloadUrl: '',
});
const chatApiKeySet = ref(false);
const savingChat = ref(false);
const chatLoadedFor = ref<string | null>(null);
function fieldLabel(value?: string | null) {
if (!value) return '—';
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
@@ -132,7 +116,6 @@ async function toggleOpen(org: PlatformOrganizationDto) {
openId.value = null;
detail.value = null;
editingId.value = null;
chatLoadedFor.value = null;
return;
}
openId.value = org.id;
@@ -143,56 +126,11 @@ async function toggleOpen(org: PlatformOrganizationDto) {
);
detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])];
await loadChatSettings(org.id);
} catch (e) {
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
}
}
async function loadChatSettings(orgId: string) {
try {
const { data } = await platformApi.get<ConversationSettingsDto>(
`/platform/organizations/${orgId}/conversations/settings`,
);
chatForm.chatHost = data.chatHost;
chatForm.uploadUrl = data.uploadUrl;
chatForm.downloadUrl = data.downloadUrl;
chatApiKeySet.value = data.apiKeySet;
chatForm.apiKey = '';
chatLoadedFor.value = orgId;
} catch (e) {
error.value = apiError(e, 'بارگذاری تنظیمات گفتگو ناموفق بود');
}
}
async function saveChatSettings(orgId: string) {
savingChat.value = true;
error.value = '';
success.value = '';
try {
const payload: UpdateConversationSettingsRequest = {
chatHost: chatForm.chatHost.trim(),
uploadUrl: chatForm.uploadUrl.trim() || undefined,
downloadUrl: chatForm.downloadUrl.trim() || undefined,
};
if (chatForm.apiKey.trim()) payload.apiKey = chatForm.apiKey.trim();
const { data } = await platformApi.patch<ConversationSettingsDto>(
`/platform/organizations/${orgId}/conversations/settings`,
payload,
);
chatApiKeySet.value = data.apiKeySet;
chatForm.apiKey = '';
chatForm.chatHost = data.chatHost;
chatForm.uploadUrl = data.uploadUrl;
chatForm.downloadUrl = data.downloadUrl;
success.value = 'تنظیمات گفتگو ذخیره شد';
} catch (e) {
error.value = apiError(e, 'ذخیره تنظیمات گفتگو ناموفق بود');
} finally {
savingChat.value = false;
}
}
function startEdit(org: PlatformOrganizationDto) {
editingId.value = org.id;
editForm.name = org.name;
@@ -368,35 +306,6 @@ 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;
}
const { data: list } = await platformApi.get<PlatformOrganizationDto[]>(
'/platform/organizations',
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
);
items.value = list;
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
onMounted(() => {
void load();
});
@@ -599,59 +508,6 @@ onMounted(() => {
</div>
</div>
<div class="chat-settings">
<div class="modules-head">
<strong>تنظیمات گفتگو</strong>
<button
class="btn"
type="button"
:disabled="savingChat || chatLoadedFor !== org.id"
@click="saveChatSettings(org.id)"
>
{{ savingChat ? 'ذخیره…' : 'ذخیره گفتگو' }}
</button>
</div>
<p class="chat-hint">
Host، API Key و آدرس آپلود/دانلود — فقط از سوپرادمین و برای همین محیط.
</p>
<div class="chat-grid">
<label>
<span>Host سرویس چت</span>
<input
v-model="chatForm.chatHost"
dir="ltr"
placeholder="chat.example.ir"
autocomplete="off"
/>
</label>
<label>
<span>{{ chatApiKeySet ? 'API Key (برای تغییر وارد کنید)' : 'API Key' }}</span>
<input
v-model="chatForm.apiKey"
type="password"
dir="ltr"
autocomplete="new-password"
/>
</label>
<label>
<span>آدرس آپلود فایل</span>
<input
v-model="chatForm.uploadUrl"
dir="ltr"
placeholder="https://chat.example.ir/upload"
/>
</label>
<label>
<span>آدرس پایه دانلود فایل</span>
<input
v-model="chatForm.downloadUrl"
dir="ltr"
placeholder="https://chat.example.ir/files"
/>
</label>
</div>
</div>
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
<ul v-else>
<li v-for="member in detail.members" :key="member.membershipId">
@@ -688,13 +544,6 @@ onMounted(() => {
</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>
@@ -933,33 +782,6 @@ label span {
border: 1px solid var(--line);
}
.chat-settings {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border-radius: 12px;
background: #f5f3ff;
border: 1px solid #e9e5ff;
}
.chat-hint {
margin: 0;
font-size: 0.82rem;
color: var(--muted);
}
.chat-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
@media (max-width: 720px) {
.chat-grid {
grid-template-columns: 1fr;
}
}
.modules-head,
.mod-group-head {
display: flex;
+3 -43
View File
@@ -1,20 +1,10 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
import type {
ApplyBillingPlanRequest,
BillingPlanCode,
PlatformUserDto,
UserRole,
} from '@mento/shared';
import {
BILLING_PLAN_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
} from '@mento/shared';
import type { PlatformUserDto, UserRole } from '@mento/shared';
import { 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);
@@ -135,25 +125,6 @@ async function setMembershipRole(userId: string, membershipId: string, role: Use
}
}
async function applyPlan(userId: string, planCode: BillingPlanCode) {
busyId.value = `plan-${userId}-${planCode}`;
error.value = '';
success.value = '';
try {
const body: ApplyBillingPlanRequest = { planCode };
const { data } = await platformApi.post<PlatformUserDto>(
`/platform/users/${userId}/billing/apply-plan`,
body,
);
items.value = items.value.map((row) => (row.id === userId ? data : row));
success.value = `پلن «${BILLING_PLAN_LABELS[planCode]}» اعمال شد`;
} catch (e) {
error.value = apiError(e, 'اعمال پلن ناموفق بود');
} finally {
busyId.value = '';
}
}
function setOwnersOnly(value: boolean) {
if (ownersOnly.value === value) return;
ownersOnly.value = value;
@@ -168,7 +139,7 @@ onMounted(() => {
<template>
<div class="page">
<header class="head">
<p class="lead">اشتراک مالک، نقش عضویت و ریست رمز.</p>
<p class="lead">نقش عضویت و ریست رمز. اشتراک از منوی اشتراک تنظیم می‌شود.</p>
<div class="head-actions">
<div class="seg" role="group" aria-label="فیلتر مالکان">
<button
@@ -264,17 +235,6 @@ onMounted(() => {
</form>
<div v-if="openId === user.id" class="detail">
<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>
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
<ul v-else>
<li v-for="mem in user.memberships" :key="mem.membershipId">
+19
View File
@@ -4613,6 +4613,22 @@ export interface ResetPlatformAdminPasswordRequest {
password: string;
}
export interface UpdatePlatformAdminRequest {
fullName: string;
mobile: string;
email?: string | null;
}
export interface PlatformMasterPasswordStatusDto {
enabled: boolean;
updatedAt?: string | null;
}
export interface SetPlatformMasterPasswordRequest {
/** null یعنی رمز کلی برداشته شود */
password: string | null;
}
export interface PlatformStatsDto {
organizationCount: number;
activeOrganizationCount: number;
@@ -4649,6 +4665,9 @@ export interface PlatformOrganizationDto {
enabledModules?: OrgModuleKey[];
/** اشتراک مالک اصلی محیط */
subscription?: SubscriptionSummaryDto | null;
ownerUserId?: string | null;
ownerName?: string | null;
chatConfigured?: boolean;
members?: PlatformOrgMemberDto[];
}