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 updatedAt DateTime @updatedAt
} }
/// رمز کلی سوپرادمین: با موبایل هر کاربر و این رمز می‌شود وارد حساب CRM او شد
model PlatformMasterPassword {
id String @id @default("default")
passwordHash String
updatedAt DateTime @updatedAt
}
/// اشتراک SaaS روی مالک حساب (نه روی هر عضویت) /// اشتراک SaaS روی مالک حساب (نه روی هر عضویت)
model BillingAccount { model BillingAccount {
id String @id @default(cuid()) id String @id @default(cuid())
+32 -12
View File
@@ -326,11 +326,12 @@ export class AuthService {
} }
const ok = await bcrypt.compare(dto.password, user.passwordHash); 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('اطلاعات ورود نادرست است'); throw new UnauthorizedException('اطلاعات ورود نادرست است');
} }
return this.finishLogin(user.id, { fromPassword: true }); return this.finishLogin(user.id, { fromPassword: !viaMaster, viaMaster });
} }
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */ /** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -692,11 +693,14 @@ export class AuthService {
selectionToken: string, selectionToken: string,
organizationId: string, organizationId: string,
): Promise<LoginResponse> { ): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string; pwd?: boolean }; let payload: { sub?: string; typ?: string; pwd?: boolean; master?: boolean };
try { try {
payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean }>( payload = this.jwt.verify<{
selectionToken, sub?: string;
); typ?: string;
pwd?: boolean;
master?: boolean;
}>(selectionToken);
} catch { } catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است'); throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
} }
@@ -705,6 +709,8 @@ export class AuthService {
} }
return this.issueLogin(payload.sub, organizationId, { return this.issueLogin(payload.sub, organizationId, {
fromPassword: payload.pwd === true, fromPassword: payload.pwd === true,
viaMaster: payload.master === true,
skipLastLogin: payload.master === true,
}); });
} }
@@ -717,15 +723,16 @@ export class AuthService {
private async finishLogin( private async finishLogin(
userId: string, userId: string,
opts?: { fromPassword?: boolean }, opts?: { fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResult> { ): Promise<LoginResult> {
const rows = await this.memberships.listActive(userId); const rows = await this.memberships.listActive(userId);
if (!rows.length) { if (!rows.length) {
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید'); throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
} }
const usable = opts?.fromPassword const usable =
? rows.filter((row) => !row.loginOtpOnly) opts?.fromPassword && !opts.viaMaster
: rows; ? rows.filter((row) => !row.loginOtpOnly)
: rows;
if (!usable.length) { if (!usable.length) {
throw new UnauthorizedException( throw new UnauthorizedException(
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.', 'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
@@ -734,6 +741,8 @@ export class AuthService {
if (usable.length === 1) { if (usable.length === 1) {
return this.issueLogin(userId, usable[0]!.organization.id, { return this.issueLogin(userId, usable[0]!.organization.id, {
fromPassword: opts?.fromPassword, fromPassword: opts?.fromPassword,
viaMaster: opts?.viaMaster,
skipLastLogin: opts?.viaMaster,
}); });
} }
return { return {
@@ -743,6 +752,7 @@ export class AuthService {
sub: userId, sub: userId,
typ: 'org-select', typ: 'org-select',
...(opts?.fromPassword ? { pwd: true } : {}), ...(opts?.fromPassword ? { pwd: true } : {}),
...(opts?.viaMaster ? { master: true } : {}),
}, },
{ expiresIn: '10m' }, { 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( private async issueLogin(
userId: string, userId: string,
organizationId: string, organizationId: string,
opts?: { skipLastLogin?: boolean; fromPassword?: boolean }, opts?: { skipLastLogin?: boolean; fromPassword?: boolean; viaMaster?: boolean },
): Promise<LoginResponse> { ): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId); const membership = await this.memberships.requireActive(userId, organizationId);
if (opts?.fromPassword) { if (opts?.fromPassword && !opts.viaMaster) {
const gate = await this.prisma.membership.findFirst({ const gate = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true }, where: { userId, organizationId, isActive: true },
select: { loginOtpOnly: true }, select: { loginOtpOnly: true },
@@ -656,7 +656,7 @@ export class ConversationsService {
}); });
if (!row) { if (!row) {
throw new BadRequestException( throw new BadRequestException(
'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → محیط‌ها → تنظیمات گفتگو پیکربندی کنید.', 'تنظیمات گفتگو برای این محیط انجام نشده است. از سوپرادمین → گفتگو پیکربندی کنید.',
); );
} }
try { try {
+25
View File
@@ -54,6 +54,31 @@ export class ResetPlatformAdminPasswordDto {
password!: string; 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 { export class UpdatePlatformStatusDto {
@IsBoolean() @IsBoolean()
isActive!: boolean; isActive!: boolean;
@@ -1,10 +1,12 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Param, Param,
Patch, Patch,
Post, Post,
Put,
Query, Query,
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -16,6 +18,8 @@ import {
CreatePlatformAdminDto, CreatePlatformAdminDto,
CreatePlatformOrganizationDto, CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto, ResetPlatformAdminPasswordDto,
SetMasterPasswordDto,
UpdatePlatformAdminDto,
ResetPlatformUserPasswordDto, ResetPlatformUserPasswordDto,
UpdateOrgModulesDto, UpdateOrgModulesDto,
UpdatePlatformMembershipRoleDto, UpdatePlatformMembershipRoleDto,
@@ -73,6 +77,26 @@ export class PlatformController {
return this.platform.resetAdminPassword(id, dto); 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') @Get('organizations')
listOrganizations(@Query('q') q?: string) { listOrganizations(@Query('q') q?: string) {
return this.platform.listOrganizations(q); return this.platform.listOrganizations(q);
+108 -2
View File
@@ -111,15 +111,23 @@ export class PlatformService {
where: { role: 'owner', isActive: true }, where: { role: 'owner', isActive: true },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
take: 1, take: 1,
select: { user: { select: { billingAccount: true } } }, select: {
userId: true,
user: { select: { fullName: true, billingAccount: true } },
},
}, },
conversationSettings: { select: { organizationId: true } },
}, },
}); });
return rows.map((row) => { return rows.map((row) => {
const account = row.memberships[0]?.user.billingAccount; const owner = row.memberships[0];
const account = owner?.user.billingAccount;
return { return {
...this.orgToDto(row), ...this.orgToDto(row),
subscription: account ? toSummary(account) : null, 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 }; 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: { private adminToDto(row: {
id: string; id: string;
mobile: string | null; mobile: string | null;
+10 -1
View File
@@ -20,6 +20,10 @@ const pageTitle = computed(() => {
return 'محیط‌ها'; return 'محیط‌ها';
case 'adm-users': case 'adm-users':
return 'کاربران'; return 'کاربران';
case 'adm-chat':
return 'گفتگو';
case 'adm-billing':
return 'اشتراک';
case 'adm-admins': case 'adm-admins':
return 'ادمین‌ها'; return 'ادمین‌ها';
case 'adm-sms': case 'adm-sms':
@@ -33,6 +37,8 @@ const tabs = [
{ name: 'adm-dashboard', label: 'خانه', icon: 'home' as const }, { name: 'adm-dashboard', label: 'خانه', icon: 'home' as const },
{ name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const }, { name: 'adm-organizations', label: 'محیط‌ها', icon: 'building' as const },
{ name: 'adm-users', label: 'کاربران', icon: 'users' 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-sms', label: 'پیامک', icon: 'mail' as const },
{ name: 'adm-admins', label: 'ادمین‌ها', icon: 'lock' as const }, { name: 'adm-admins', label: 'ادمین‌ها', icon: 'lock' as const },
]; ];
@@ -372,7 +378,10 @@ function onLogout() {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
z-index: 30; 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; gap: 0.15rem;
padding: 0.45rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom)); padding: 0.45rem 0.55rem calc(0.45rem + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92); background: rgba(255, 255, 255, 0.92);
+10
View File
@@ -57,6 +57,16 @@ export const router = createRouter({
name: 'adm-users', name: 'adm-users',
component: () => import('@/views/adm/AdmUsersView.vue'), 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', path: 'admins',
name: 'adm-admins', name: 'adm-admins',
+168 -2
View File
@@ -1,7 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'; import { onMounted, reactive, ref } from 'vue';
import axios from 'axios'; 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 { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric } from '@/lib/jalali'; import { formatJalaliNumeric } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth'; import { usePlatformAuthStore } from '@/stores/platform-auth';
@@ -17,6 +22,13 @@ const creating = ref(false);
const resetId = ref<string | null>(null); const resetId = ref<string | null>(null);
const resetPassword = ref(''); const resetPassword = ref('');
const resetting = ref(false); 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>({ const createForm = reactive<CreatePlatformAdminRequest>({
fullName: '', fullName: '',
@@ -39,6 +51,12 @@ async function load() {
try { try {
const { data } = await platformApi.get<PlatformAdminDto[]>('/platform/admins'); const { data } = await platformApi.get<PlatformAdminDto[]>('/platform/admins');
items.value = data; items.value = data;
try {
const master = await platformApi.get<PlatformMasterPasswordStatusDto>('/platform/master-password');
masterEnabled.value = master.data.enabled;
} catch {
masterEnabled.value = false;
}
} catch (e) { } catch (e) {
error.value = apiError(e, 'بارگذاری ادمین‌ها ناموفق بود'); error.value = apiError(e, 'بارگذاری ادمین‌ها ناموفق بود');
} finally { } 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() { function resetCreateForm() {
createForm.fullName = ''; createForm.fullName = '';
createForm.mobile = ''; createForm.mobile = '';
@@ -125,12 +218,38 @@ onMounted(() => {
<template> <template>
<div class="page"> <div class="page">
<header class="head"> <header class="head">
<p class="lead">افزودن سوپرادمین با موبایل، فعال/غیرفعال و ریست رمز.</p> <p class="lead">افزودن، ویرایش و حذف سوپرادمین. رمز کلی با موبایل هر کاربر وارد حساب CRM او می‌شود.</p>
<button class="btn" type="button" @click="showCreate = !showCreate"> <button class="btn" type="button" @click="showCreate = !showCreate">
{{ showCreate ? 'بستن فرم' : 'ادمین جدید' }} {{ showCreate ? 'بستن فرم' : 'ادمین جدید' }}
</button> </button>
</header> </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-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p> <p v-else-if="success" class="ok">{{ success }}</p>
@@ -188,6 +307,9 @@ onMounted(() => {
</div> </div>
</div> </div>
<div class="actions"> <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 class="btn btn-ghost" type="button" @click="startReset(admin.id)">
ریست رمز ریست رمز
</button> </button>
@@ -199,6 +321,49 @@ onMounted(() => {
> >
{{ admin.isActive ? 'غیرفعال' : 'فعال' }} {{ admin.isActive ? 'غیرفعال' : 'فعال' }}
</button> </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> </div>
<form <form
@@ -353,6 +518,7 @@ input {
display: flex; display: flex;
gap: 0.45rem; gap: 0.45rem;
align-items: flex-start; align-items: flex-start;
flex-wrap: wrap;
} }
.on, .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"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router'; 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 { platformApi } from '@/lib/platform-api';
import { toFaDigits } from '@/lib/jalali'; import { toFaDigits } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth'; import { usePlatformAuthStore } from '@/stores/platform-auth';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
type FilterKey = 'organizations' | 'users' | 'memberships' | 'expiring' | 'expired' | 'chat';
const platform = usePlatformAuthStore(); const platform = usePlatformAuthStore();
const loading = ref(true); const loading = ref(true);
const error = ref(''); const error = ref('');
const listError = ref('');
const listLoading = ref(false);
const stats = ref<PlatformStatsDto | null>(null); const stats = ref<PlatformStatsDto | null>(null);
const chatStats = ref<PlatformConversationStatsDto | 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() { async function load() {
error.value = ''; 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(() => { onMounted(() => {
void load(); void load();
}); });
@@ -50,56 +172,100 @@ onMounted(() => {
<p v-else-if="loading" class="muted">در حال بارگذاری…</p> <p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-else-if="stats" class="kpis"> <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> <span class="ico"><UiIcon name="building" :size="18" /></span>
<div> <div>
<p>محیط‌ها</p> <p>محیط‌ها</p>
<strong>{{ toFaDigits(stats.organizationCount) }}</strong> <strong>{{ toFaDigits(stats.organizationCount) }}</strong>
<small>{{ toFaDigits(stats.activeOrganizationCount) }} فعال</small> <small>{{ toFaDigits(stats.activeOrganizationCount) }} فعال</small>
</div> </div>
</article> </button>
<article class="kpi"> <button
type="button"
class="kpi"
:class="{ on: filter === 'users' }"
@click="toggleFilter('users')"
>
<span class="ico"><UiIcon name="users" :size="18" /></span> <span class="ico"><UiIcon name="users" :size="18" /></span>
<div> <div>
<p>کاربران</p> <p>کاربران</p>
<strong>{{ toFaDigits(stats.userCount) }}</strong> <strong>{{ toFaDigits(stats.userCount) }}</strong>
<small>{{ toFaDigits(stats.activeUserCount) }} فعال</small> <small>{{ toFaDigits(stats.activeUserCount) }} فعال</small>
</div> </div>
</article> </button>
<article class="kpi"> <button
type="button"
class="kpi"
:class="{ on: filter === 'memberships' }"
@click="toggleFilter('memberships')"
>
<span class="ico"><UiIcon name="layers" :size="18" /></span> <span class="ico"><UiIcon name="layers" :size="18" /></span>
<div> <div>
<p>عضویت‌ها</p> <p>عضویت‌ها</p>
<strong>{{ toFaDigits(stats.membershipCount) }}</strong> <strong>{{ toFaDigits(stats.membershipCount) }}</strong>
<small>نقش در محیط‌ها</small> <small>نقش در محیط‌ها</small>
</div> </div>
</article> </button>
<article class="kpi warn"> <button
type="button"
class="kpi warn"
:class="{ on: filter === 'expiring' }"
@click="toggleFilter('expiring')"
>
<span class="ico"><UiIcon name="clock" :size="18" /></span> <span class="ico"><UiIcon name="clock" :size="18" /></span>
<div> <div>
<p>نزدیک انقضا</p> <p>نزدیک انقضا</p>
<strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong> <strong>{{ toFaDigits(stats.expiringSoonCount ?? 0) }}</strong>
<small>کمتر از ۳ روز</small> <small>کمتر از ۳ روز</small>
</div> </div>
</article> </button>
<article class="kpi danger"> <button
type="button"
class="kpi danger"
:class="{ on: filter === 'expired' }"
@click="toggleFilter('expired')"
>
<span class="ico"><UiIcon name="warning" :size="18" /></span> <span class="ico"><UiIcon name="warning" :size="18" /></span>
<div> <div>
<p>منقضی</p> <p>منقضی</p>
<strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong> <strong>{{ toFaDigits(stats.expiredBillingCount ?? 0) }}</strong>
<small>نیاز به تمدید</small> <small>نیاز به تمدید</small>
</div> </div>
</article> </button>
<article v-if="chatStats" class="kpi"> <button
v-if="chatStats"
type="button"
class="kpi"
:class="{ on: filter === 'chat' }"
@click="toggleFilter('chat')"
>
<span class="ico"><UiIcon name="chat" :size="18" /></span> <span class="ico"><UiIcon name="chat" :size="18" /></span>
<div> <div>
<p>گفتگو (پیکربندی‌شده)</p> <p>گفتگو (پیکربندی‌شده)</p>
<strong>{{ toFaDigits(chatStats.organizationsWithChat) }}</strong> <strong>{{ toFaDigits(chatStats.organizationsWithChat) }}</strong>
<small>{{ toFaDigits(chatStats.totalRoomsTracked) }} room ثبت‌شده</small> <small>{{ toFaDigits(chatStats.totalRoomsTracked) }} room ثبت‌شده</small>
</div> </div>
</article> </button>
</div> </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"> <section class="actions">
<h3>میانبرها</h3> <h3>میانبرها</h3>
<div class="shortcuts"> <div class="shortcuts">
@@ -202,6 +368,11 @@ onMounted(() => {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
align-items: flex-start; align-items: flex-start;
width: 100%;
text-align: start;
font: inherit;
color: inherit;
cursor: pointer;
background: #fff; background: #fff;
border: 1px solid #eef2f7; border: 1px solid #eef2f7;
border-radius: 18px; border-radius: 18px;
@@ -209,6 +380,11 @@ onMounted(() => {
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04); 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 p,
.kpi small { .kpi small {
margin: 0; margin: 0;
@@ -245,6 +421,39 @@ onMounted(() => {
color: var(--danger); 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 { .actions h3 {
margin: 0 0 0.65rem; margin: 0 0 0.65rem;
font-size: 0.95rem; font-size: 0.95rem;
@@ -2,14 +2,9 @@
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,
ConversationSettingsDto,
CreatePlatformOrganizationRequest, CreatePlatformOrganizationRequest,
OrgModuleKey, OrgModuleKey,
PlatformOrganizationDto, PlatformOrganizationDto,
PlatformUserDto,
UpdateConversationSettingsRequest,
UpdatePlatformOrganizationRequest, UpdatePlatformOrganizationRequest,
UserRole, UserRole,
} from '@mento/shared'; } from '@mento/shared';
@@ -25,7 +20,6 @@ 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);
@@ -58,16 +52,6 @@ const editForm = reactive({
const moduleDraft = ref<OrgModuleKey[]>([]); const moduleDraft = ref<OrgModuleKey[]>([]);
const savingModules = ref(false); 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) { function fieldLabel(value?: string | null) {
if (!value) return '—'; if (!value) return '—';
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value; return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
@@ -132,7 +116,6 @@ async function toggleOpen(org: PlatformOrganizationDto) {
openId.value = null; openId.value = null;
detail.value = null; detail.value = null;
editingId.value = null; editingId.value = null;
chatLoadedFor.value = null;
return; return;
} }
openId.value = org.id; openId.value = org.id;
@@ -143,56 +126,11 @@ async function toggleOpen(org: PlatformOrganizationDto) {
); );
detail.value = data; detail.value = data;
moduleDraft.value = [...(data.enabledModules ?? [])]; moduleDraft.value = [...(data.enabledModules ?? [])];
await loadChatSettings(org.id);
} catch (e) { } catch (e) {
error.value = apiError(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) { function startEdit(org: PlatformOrganizationDto) {
editingId.value = org.id; editingId.value = org.id;
editForm.name = org.name; 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(() => { onMounted(() => {
void load(); void load();
}); });
@@ -599,59 +508,6 @@ onMounted(() => {
</div> </div>
</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> <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">
@@ -688,13 +544,6 @@ onMounted(() => {
</button> </button>
</div> </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>
@@ -933,33 +782,6 @@ label span {
border: 1px solid var(--line); 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, .modules-head,
.mod-group-head { .mod-group-head {
display: flex; display: flex;
+3 -43
View File
@@ -1,20 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import axios from 'axios'; import axios from 'axios';
import type { import type { PlatformUserDto, UserRole } from '@mento/shared';
ApplyBillingPlanRequest, import { USER_ROLE_LABELS, USER_ROLES } from '@mento/shared';
BillingPlanCode,
PlatformUserDto,
UserRole,
} from '@mento/shared';
import {
BILLING_PLAN_LABELS,
USER_ROLE_LABELS,
USER_ROLES,
} 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 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) { function setOwnersOnly(value: boolean) {
if (ownersOnly.value === value) return; if (ownersOnly.value === value) return;
ownersOnly.value = value; ownersOnly.value = value;
@@ -168,7 +139,7 @@ onMounted(() => {
<template> <template>
<div class="page"> <div class="page">
<header class="head"> <header class="head">
<p class="lead">اشتراک مالک، نقش عضویت و ریست رمز.</p> <p class="lead">نقش عضویت و ریست رمز. اشتراک از منوی اشتراک تنظیم می‌شود.</p>
<div class="head-actions"> <div class="head-actions">
<div class="seg" role="group" aria-label="فیلتر مالکان"> <div class="seg" role="group" aria-label="فیلتر مالکان">
<button <button
@@ -264,17 +235,6 @@ onMounted(() => {
</form> </form>
<div v-if="openId === user.id" class="detail"> <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> <p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
<ul v-else> <ul v-else>
<li v-for="mem in user.memberships" :key="mem.membershipId"> <li v-for="mem in user.memberships" :key="mem.membershipId">
+19
View File
@@ -4613,6 +4613,22 @@ export interface ResetPlatformAdminPasswordRequest {
password: string; 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 { export interface PlatformStatsDto {
organizationCount: number; organizationCount: number;
activeOrganizationCount: number; activeOrganizationCount: number;
@@ -4649,6 +4665,9 @@ export interface PlatformOrganizationDto {
enabledModules?: OrgModuleKey[]; enabledModules?: OrgModuleKey[];
/** اشتراک مالک اصلی محیط */ /** اشتراک مالک اصلی محیط */
subscription?: SubscriptionSummaryDto | null; subscription?: SubscriptionSummaryDto | null;
ownerUserId?: string | null;
ownerName?: string | null;
chatConfigured?: boolean;
members?: PlatformOrgMemberDto[]; members?: PlatformOrgMemberDto[];
} }