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:
@@ -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")
|
||||
);
|
||||
@@ -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())
|
||||
|
||||
@@ -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,15 +723,16 @@ 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
|
||||
? rows.filter((row) => !row.loginOtpOnly)
|
||||
: rows;
|
||||
const usable =
|
||||
opts?.fromPassword && !opts.viaMaster
|
||||
? rows.filter((row) => !row.loginOtpOnly)
|
||||
: rows;
|
||||
if (!usable.length) {
|
||||
throw new UnauthorizedException(
|
||||
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user