Edit login suoerAdmin

This commit is contained in:
2026-09-19 22:42:51 +03:30
parent b016b4754f
commit 605e1d3340
19 changed files with 717 additions and 43 deletions
+5 -3
View File
@@ -24,10 +24,12 @@ SMS_SENDER=
# مثال ساخت: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
INTEGRATIONS_ENCRYPTION_KEY=
# سوپرادمین پلتفرم — ورود از /adm
# در لوکال اگر خالی بماند: platform@demo.local / demo1234
PLATFORM_ADMIN_EMAIL=
# سوپرادمین پلتفرم — ورود از /adm با موبایل + رمز
# در لوکال اگر خالی بماند: 09120000000 / demo1234
PLATFORM_ADMIN_MOBILE=
PLATFORM_ADMIN_PASSWORD=
PLATFORM_ADMIN_NAME=سوپرادمین
# اختیاری
# PLATFORM_ADMIN_EMAIL=
# Production: see deploy/.env.production.example and deploy/DEPLOY.md
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "PlatformAdmin" ADD COLUMN IF NOT EXISTS "mobile" TEXT;
-- Make email optional (existing rows keep their email)
ALTER TABLE "PlatformAdmin" ALTER COLUMN "email" DROP NOT NULL;
-- Unique mobile
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_mobile_key" ON "PlatformAdmin"("mobile");
+3 -1
View File
@@ -282,7 +282,9 @@ model Membership {
/// حساب سوپرادمین پلتفرم — جدا از User سازمانی
model PlatformAdmin {
id String @id @default(cuid())
email String @unique
/// ورود اصلی با موبایل نرمال‌شده (مثلاً 0912…)
mobile String? @unique
email String? @unique
passwordHash String
fullName String
isActive Boolean @default(true)
+36 -8
View File
@@ -14,16 +14,18 @@ const DEFAULT_STAGES = [
] as const;
async function seedPlatformAdmin() {
const envMobile = (process.env.PLATFORM_ADMIN_MOBILE ?? '').trim();
const envEmail = (process.env.PLATFORM_ADMIN_EMAIL ?? '').trim().toLowerCase();
const envPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? '';
const envName = (process.env.PLATFORM_ADMIN_NAME ?? 'سوپرادمین').trim() || 'سوپرادمین';
const isProd = process.env.NODE_ENV === 'production';
const email = envEmail || (isProd ? '' : 'platform@demo.local');
const mobileRaw = envMobile || (isProd ? '' : '09120000000');
const password = envPassword || (isProd ? '' : 'demo1234');
const email = envEmail || null;
if (!email || !password) {
if (!mobileRaw || !password) {
console.log(
'Platform admin seed skipped (set PLATFORM_ADMIN_EMAIL and PLATFORM_ADMIN_PASSWORD)',
'Platform admin seed skipped (set PLATFORM_ADMIN_MOBILE and PLATFORM_ADMIN_PASSWORD)',
);
return;
}
@@ -32,17 +34,43 @@ async function seedPlatformAdmin() {
return;
}
const existing = await prisma.platformAdmin.findUnique({ where: { email } });
if (existing) {
console.log('Platform admin exists:', email);
// normalize: 09xxxxxxxxx
let mobile = mobileRaw.replace(/[\s\-()]/g, '');
if (mobile.startsWith('+98')) mobile = `0${mobile.slice(3)}`;
if (mobile.startsWith('98') && mobile.length === 12) mobile = `0${mobile.slice(2)}`;
if (/^9\d{9}$/.test(mobile)) mobile = `0${mobile}`;
if (!/^09\d{9}$/.test(mobile)) {
console.log('Platform admin seed skipped (PLATFORM_ADMIN_MOBILE invalid)');
return;
}
const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } });
if (existingByMobile) {
console.log('Platform admin exists:', mobile);
return;
}
if (email) {
const existingByEmail = await prisma.platformAdmin.findUnique({ where: { email } });
if (existingByEmail) {
await prisma.platformAdmin.update({
where: { id: existingByEmail.id },
data: { mobile, ...(envName ? { fullName: envName } : {}) },
});
console.log('Platform admin linked mobile:', mobile, '→', email);
return;
}
}
const passwordHash = await bcrypt.hash(password, 10);
await prisma.platformAdmin.create({
data: { email, passwordHash, fullName: envName },
data: {
mobile,
email,
passwordHash,
fullName: envName,
},
});
console.log('Seeded platform admin:', email);
console.log('Seeded platform admin:', mobile);
}
async function main() {
+32 -2
View File
@@ -11,14 +11,44 @@ import {
import { USER_ROLES, type UserRole } from '@mento/shared';
export class PlatformLoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsString()
@MinLength(6)
password!: string;
}
export class CreatePlatformAdminDto {
@IsString()
@MinLength(2)
@MaxLength(120)
fullName!: string;
@IsString()
@MinLength(8)
@MaxLength(40)
mobile!: string;
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string;
@IsOptional()
@IsEmail()
email?: string;
}
export class ResetPlatformAdminPasswordDto {
@IsString()
@MinLength(8)
@MaxLength(120)
password!: string;
}
export class UpdatePlatformStatusDto {
@IsBoolean()
isActive!: boolean;
+24 -5
View File
@@ -1,7 +1,13 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared';
import { normalizeMobile } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { PlatformLoginDto } from './dto';
@@ -13,9 +19,12 @@ export class PlatformAuthService {
) {}
async login(dto: PlatformLoginDto): Promise<PlatformLoginResponse> {
const email = dto.email.trim().toLowerCase();
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
const admin = await this.prisma.platformAdmin.findUnique({
where: { email },
where: { mobile },
});
if (!admin || admin.isActive === false) {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
@@ -47,15 +56,25 @@ export class PlatformAuthService {
return this.toDto(admin);
}
private toDto(admin: {
toDto(admin: {
id: string;
email: string;
mobile: string | null;
email: string | null;
fullName: string;
isActive?: boolean;
lastLoginAt?: Date | null;
createdAt?: Date;
}): PlatformAdminDto {
return {
id: admin.id,
mobile: admin.mobile,
email: admin.email,
fullName: admin.fullName,
...(admin.isActive !== undefined ? { isActive: admin.isActive } : {}),
...(admin.lastLoginAt !== undefined
? { lastLoginAt: admin.lastLoginAt?.toISOString() ?? null }
: {}),
...(admin.createdAt ? { createdAt: admin.createdAt.toISOString() } : {}),
};
}
}
@@ -11,7 +11,8 @@ export type PlatformJwtPayload = {
export type PlatformActor = {
platformAdminId: string;
email: string;
mobile: string | null;
email: string | null;
fullName: string;
};
@@ -39,6 +40,7 @@ export class PlatformJwtStrategy extends PassportStrategy(
where: { id: payload.sub },
select: {
id: true,
mobile: true,
email: true,
fullName: true,
isActive: true,
@@ -49,6 +51,7 @@ export class PlatformJwtStrategy extends PassportStrategy(
}
return {
platformAdminId: admin.id,
mobile: admin.mobile,
email: admin.email,
fullName: admin.fullName,
};
@@ -8,14 +8,18 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto,
UpdatePlatformMembershipRoleDto,
UpdatePlatformOrganizationDto,
UpdatePlatformStatusDto,
} from './dto';
import { PlatformAuthGuard } from './platform-auth.guard';
import type { PlatformActor } from './platform-jwt.strategy';
import { PlatformService } from './platform.service';
@Controller('platform')
@@ -28,6 +32,33 @@ export class PlatformController {
return this.platform.stats();
}
@Get('admins')
listAdmins() {
return this.platform.listAdmins();
}
@Post('admins')
createAdmin(@Body() dto: CreatePlatformAdminDto) {
return this.platform.createAdmin(dto);
}
@Patch('admins/:id')
setAdminActive(
@Param('id') id: string,
@Body() dto: UpdatePlatformStatusDto,
@CurrentUser() actor: PlatformActor,
) {
return this.platform.setAdminActive(id, dto.isActive, actor.platformAdminId);
}
@Patch('admins/:id/password')
resetAdminPassword(
@Param('id') id: string,
@Body() dto: ResetPlatformAdminPasswordDto,
) {
return this.platform.resetAdminPassword(id, dto);
}
@Get('organizations')
listOrganizations(@Query('q') q?: string) {
return this.platform.listOrganizations(q);
+130
View File
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import type {
PlatformAdminDto,
PlatformOrganizationDto,
PlatformStatsDto,
PlatformUserDto,
@@ -19,7 +20,9 @@ import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
import { PrismaService } from '../prisma/prisma.service';
import type {
CreatePlatformAdminDto,
CreatePlatformOrganizationDto,
ResetPlatformAdminPasswordDto,
ResetPlatformUserPasswordDto,
UpdatePlatformOrganizationDto,
} from './dto';
@@ -472,6 +475,133 @@ export class PlatformService {
return this.getUser(row.userId);
}
async listAdmins(): Promise<PlatformAdminDto[]> {
const rows = await this.prisma.platformAdmin.findMany({
orderBy: { createdAt: 'asc' },
});
return rows.map((row) => this.adminToDto(row));
}
async createAdmin(dto: CreatePlatformAdminDto): Promise<PlatformAdminDto> {
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام معتبر نیست');
}
const mobile = normalizeMobile(dto.mobile);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const email = dto.email?.trim().toLowerCase() || null;
const existingMobile = await this.prisma.platformAdmin.findUnique({
where: { mobile },
select: { id: true },
});
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
if (email) {
const existingEmail = await this.prisma.platformAdmin.findUnique({
where: { email },
select: { id: true },
});
if (existingEmail) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
}
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const created = await this.prisma.platformAdmin.create({
data: {
fullName,
mobile,
email,
passwordHash,
},
});
return this.adminToDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('موبایل یا ایمیل تکراری است');
}
throw e;
}
}
async setAdminActive(
id: string,
isActive: boolean,
actorId: string,
): Promise<PlatformAdminDto> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
if (!isActive && id === actorId) {
throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید');
}
if (!isActive) {
const activeCount = await this.prisma.platformAdmin.count({
where: { isActive: true, id: { not: id } },
});
if (activeCount < 1) {
throw new BadRequestException('حداقل یک سوپرادمین فعال لازم است');
}
}
const updated = await this.prisma.platformAdmin.update({
where: { id },
data: { isActive },
});
return this.adminToDto(updated);
}
async resetAdminPassword(
id: string,
dto: ResetPlatformAdminPasswordDto,
): Promise<{ ok: true }> {
const existing = await this.prisma.platformAdmin.findUnique({
where: { id },
select: { id: true },
});
if (!existing) throw new NotFoundException('ادمین پیدا نشد');
const password = dto.password.trim();
if (password.length < 8) {
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
}
const passwordHash = await bcrypt.hash(password, 10);
await this.prisma.platformAdmin.update({
where: { id },
data: { passwordHash },
});
return { ok: true };
}
private adminToDto(row: {
id: string;
mobile: string | null;
email: string | null;
fullName: string;
isActive: boolean;
lastLoginAt: Date | null;
createdAt: Date;
}): PlatformAdminDto {
return {
id: row.id,
mobile: row.mobile,
email: row.email,
fullName: row.fullName,
isActive: row.isActive,
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
};
}
private orgToDto(row: {
id: string;
name: string;
+5 -1
View File
@@ -41,6 +41,10 @@ function onLogout() {
<UiIcon name="users" :size="18" />
<span>کاربران</span>
</RouterLink>
<RouterLink :to="{ name: 'adm-admins' }" class="nav-item">
<UiIcon name="lock" :size="18" />
<span>ادمین‌ها</span>
</RouterLink>
</nav>
<div class="side-foot">
@@ -160,7 +164,7 @@ function onLogout() {
}
.nav {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
flex: none;
}
+5
View File
@@ -56,6 +56,11 @@ export const router = createRouter({
name: 'adm-users',
component: () => import('@/views/adm/AdmUsersView.vue'),
},
{
path: 'admins',
name: 'adm-admins',
component: () => import('@/views/adm/AdmAdminsView.vue'),
},
],
},
{
+2 -2
View File
@@ -28,10 +28,10 @@ export const usePlatformAuthStore = defineStore('platform-auth', () => {
localStorage.setItem(ADMIN_KEY, JSON.stringify(payload.admin));
}
async function login(email: string, password: string) {
async function login(mobile: string, password: string) {
const { data } = await platformApi.post<PlatformLoginResponse>(
'/platform/auth/login',
{ email, password },
{ mobile, password },
);
persist(data);
}
+386
View File
@@ -0,0 +1,386 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import axios from 'axios';
import type { CreatePlatformAdminRequest, PlatformAdminDto } from '@mento/shared';
import { platformApi } from '@/lib/platform-api';
import { formatJalaliNumeric } from '@/lib/jalali';
import { usePlatformAuthStore } from '@/stores/platform-auth';
const platform = usePlatformAuthStore();
const loading = ref(false);
const error = ref('');
const success = ref('');
const items = ref<PlatformAdminDto[]>([]);
const busyId = ref('');
const showCreate = ref(false);
const creating = ref(false);
const resetId = ref<string | null>(null);
const resetPassword = ref('');
const resetting = ref(false);
const createForm = reactive<CreatePlatformAdminRequest>({
fullName: '',
mobile: '',
password: '',
email: '',
});
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 load() {
loading.value = true;
error.value = '';
try {
const { data } = await platformApi.get<PlatformAdminDto[]>('/platform/admins');
items.value = data;
} catch (e) {
error.value = apiError(e, 'بارگذاری ادمین‌ها ناموفق بود');
} finally {
loading.value = false;
}
}
function resetCreateForm() {
createForm.fullName = '';
createForm.mobile = '';
createForm.password = '';
createForm.email = '';
}
async function createAdmin() {
creating.value = true;
error.value = '';
success.value = '';
try {
const body: CreatePlatformAdminRequest = {
fullName: createForm.fullName.trim(),
mobile: createForm.mobile.trim(),
password: createForm.password,
email: createForm.email?.trim() || undefined,
};
const { data } = await platformApi.post<PlatformAdminDto>('/platform/admins', body);
items.value = [...items.value, data];
showCreate.value = false;
resetCreateForm();
success.value = `ادمین «${data.fullName}» اضافه شد`;
} catch (e) {
error.value = apiError(e, 'افزودن ادمین ناموفق بود');
} finally {
creating.value = false;
}
}
async function setActive(admin: PlatformAdminDto, isActive: boolean) {
busyId.value = admin.id;
error.value = '';
try {
const { data } = await platformApi.patch<PlatformAdminDto>(
`/platform/admins/${admin.id}`,
{ isActive },
);
items.value = items.value.map((row) => (row.id === admin.id ? data : row));
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ناموفق بود');
} finally {
busyId.value = '';
}
}
function startReset(id: string) {
resetId.value = resetId.value === id ? null : id;
resetPassword.value = '';
error.value = '';
success.value = '';
}
async function submitReset(id: string) {
resetting.value = true;
error.value = '';
success.value = '';
try {
await platformApi.patch(`/platform/admins/${id}/password`, {
password: resetPassword.value,
});
success.value = 'رمز عبور به‌روز شد';
resetId.value = null;
resetPassword.value = '';
} catch (e) {
error.value = apiError(e, 'ریست رمز ناموفق بود');
} finally {
resetting.value = false;
}
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<header class="head">
<div>
<h1>سوپرادمین‌ها</h1>
<p>افزودن ادمین با موبایل و رمز ثابت، فعال/غیرفعال و ریست رمز.</p>
</div>
<button class="btn" type="button" @click="showCreate = !showCreate">
{{ showCreate ? 'بستن فرم' : 'ادمین جدید' }}
</button>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="success" class="ok">{{ success }}</p>
<form v-if="showCreate" class="panel" @submit.prevent="createAdmin">
<h2>افزودن سوپرادمین</h2>
<div class="grid">
<label>
<span>نام</span>
<input v-model="createForm.fullName" required minlength="2" />
</label>
<label>
<span>موبایل</span>
<input v-model="createForm.mobile" type="tel" required dir="ltr" placeholder="09xxxxxxxxx" />
</label>
<label>
<span>رمز عبور</span>
<input v-model="createForm.password" type="password" required minlength="8" />
</label>
<label>
<span>ایمیل (اختیاری)</span>
<input v-model="createForm.email" type="email" dir="ltr" />
</label>
</div>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="creating">
{{ creating ? 'در حال ذخیره…' : 'افزودن' }}
</button>
</div>
</form>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="!items.length" class="muted">ادمینی ثبت نشده.</p>
<div v-else class="list">
<article v-for="admin in items" :key="admin.id" class="row">
<div class="row-main">
<div>
<strong>
{{ admin.fullName }}
<em v-if="admin.id === platform.admin?.id" class="you">شما</em>
</strong>
<span>
{{ admin.mobile || '—' }}
<template v-if="admin.email"> · {{ admin.email }}</template>
</span>
</div>
<div class="meta">
<em :class="admin.isActive ? 'on' : 'off'">
{{ admin.isActive ? 'فعال' : 'غیرفعال' }}
</em>
<small v-if="admin.lastLoginAt">
آخرین ورود {{ formatJalaliNumeric(new Date(admin.lastLoginAt)) }}
</small>
<small v-else>هنوز وارد نشده</small>
</div>
</div>
<div class="actions">
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
ریست رمز
</button>
<button
class="btn"
type="button"
:disabled="busyId === admin.id || admin.id === platform.admin?.id"
@click="setActive(admin, !admin.isActive)"
>
{{ admin.isActive ? 'غیرفعال' : 'فعال' }}
</button>
</div>
<form
v-if="resetId === admin.id"
class="reset-panel"
@submit.prevent="submitReset(admin.id)"
>
<label>
<span>رمز جدید (حداقل ۸ کاراکتر)</span>
<input
v-model="resetPassword"
type="password"
required
minlength="8"
autocomplete="new-password"
/>
</label>
<div class="panel-actions">
<button class="btn" type="submit" :disabled="resetting">ذخیره رمز</button>
<button class="btn btn-ghost" type="button" @click="startReset(admin.id)">
انصراف
</button>
</div>
</form>
</article>
</div>
</div>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 1040px;
}
.head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
.head h1 {
margin: 0 0 0.3rem;
}
.head p,
.muted {
margin: 0;
color: var(--muted);
}
.error {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
.panel,
.reset-panel {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 1rem;
display: grid;
gap: 0.85rem;
}
.panel h2 {
margin: 0;
font-size: 1rem;
}
.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 {
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;
}
.list {
display: grid;
gap: 0.7rem;
}
.row {
background: #fff;
border: 1px solid #eef2f7;
border-radius: 14px;
padding: 0.85rem 1rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.7rem 1rem;
}
.row-main {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.row-main span,
.row-main small {
display: block;
color: var(--muted);
font-size: 0.8rem;
margin-top: 0.2rem;
}
.you {
margin-inline-start: 0.4rem;
font-style: normal;
font-size: 0.75rem;
color: var(--accent-deep);
font-weight: 800;
}
.meta {
text-align: end;
}
.actions {
display: flex;
gap: 0.45rem;
align-items: flex-start;
}
.on,
.off {
font-style: normal;
font-size: 0.78rem;
font-weight: 800;
}
.on {
color: var(--success-deep);
}
.off {
color: var(--danger);
}
.reset-panel {
grid-column: 1 / -1;
background: #f8fafc;
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
+8 -1
View File
@@ -82,6 +82,13 @@ onMounted(() => {
<span>نقش، دسترسی و ریست رمز عبور</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'adm-admins' }" class="card">
<UiIcon name="lock" :size="18" />
<div>
<strong>سوپرادمین‌ها</strong>
<span>افزودن ادمین با موبایل و رمز</span>
</div>
</RouterLink>
</div>
</div>
</template>
@@ -156,7 +163,7 @@ onMounted(() => {
.shortcuts {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.85rem;
}
+9 -8
View File
@@ -12,7 +12,7 @@ import { brand } from '@mento/shared';
const platform = usePlatformAuthStore();
const router = useRouter();
const email = ref('');
const mobile = ref('');
const password = ref('');
const showPassword = ref(false);
const error = ref('');
@@ -33,7 +33,7 @@ async function submit() {
error.value = '';
loading.value = true;
try {
await platform.login(email.value.trim(), password.value);
await platform.login(mobile.value.trim(), password.value);
await router.push({ name: 'adm-dashboard' });
} catch (e) {
error.value = apiError(e, 'ورود ناموفق بود');
@@ -64,19 +64,20 @@ async function submit() {
<form class="form" @submit.prevent="submit">
<div class="field">
<label for="adm-email">ایمیل</label>
<label for="adm-mobile">شماره موبایل</label>
<div class="input-wrap">
<input
id="adm-email"
v-model="email"
type="email"
id="adm-mobile"
v-model="mobile"
type="tel"
required
autocomplete="username"
placeholder="ایمیل سوپرادمین را وارد کنید"
placeholder="09xxxxxxxxx"
class="ltr-value"
dir="ltr"
/>
<span class="field-ico" aria-hidden="true">
<UiIcon name="mail" :size="18" />
<UiIcon name="phone" :size="18" />
</span>
</div>
</div>
+3 -1
View File
@@ -40,6 +40,8 @@ STT_TIMEOUT_MS=60000
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
PLATFORM_ADMIN_EMAIL=admin@montzar.ir
# سوپرادمین پلتفرم — ورود با موبایل + رمز
PLATFORM_ADMIN_MOBILE=09121234567
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
PLATFORM_ADMIN_NAME=سوپرادمین
# PLATFORM_ADMIN_EMAIL=
+3 -2
View File
@@ -45,8 +45,9 @@ STT_TIMEOUT_MS=60000
# رمزنگاری توکن‌های اتصال حسابداری (ته‌حساب) — ۳۲ بایت hex (۶۴ کاراکتر)
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
# سوپرادمین پلتفرم — crm.montzar.ir/adm
# سوپرادمین پلتفرم — crm.montzar.ir/adm (ورود با موبایل + رمز)
# بعد از تنظیم، از apps/api: npx prisma db seed
PLATFORM_ADMIN_EMAIL=admin@montzar.ir
PLATFORM_ADMIN_MOBILE=09121234567
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
PLATFORM_ADMIN_NAME=سوپرادمین
# PLATFORM_ADMIN_EMAIL=
+6 -6
View File
@@ -87,7 +87,7 @@ nano apps/api/.env
| `SMS_*` | همان ترِز مونتایم |
| `PORTAL_SHOW_OTP` | در production حتماً `false` |
| `INTEGRATIONS_ENCRYPTION_KEY` | ۶۴ کاراکتر hex برای رمز توکن ته‌حساب (`openssl rand -hex 32`) |
| `PLATFORM_ADMIN_EMAIL` / `PASSWORD` / `NAME` | حساب سوپرادمین برای `/adm` (با `npx prisma db seed`) |
| `PLATFORM_ADMIN_MOBILE` / `PASSWORD` / `NAME` | حساب اولیه سوپرادمین برای `/adm` (با `npx prisma db seed`) |
نمونهٔ اتصال:
@@ -193,27 +193,27 @@ sudo nginx -t && sudo systemctl reload nginx
## پنل سوپرادمین (`/adm`)
مسیر جدا روی nginx لازم نیست؛ همان SPA (`crm.montzar.ir`) روت `/adm` را سرو می‌کند.
مسیر جدا روی nginx لازم نیست؛ همان SPA (`crm.montzar.ir`) روت `/adm` را سرو می‌کند. ورود با **موبایل + رمز** است.
1. در `apps/api/.env` این کلیدها را بگذارید (نمونه در `deploy/.env.production.example`):
```text
PLATFORM_ADMIN_EMAIL=admin@montzar.ir
PLATFORM_ADMIN_MOBILE=09121234567
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
PLATFORM_ADMIN_NAME=سوپرادمین
```
2. بعد از `prisma migrate deploy` یک‌بار seed بزنید تا حساب ساخته شود:
2. بعد از `prisma migrate deploy` یک‌بار seed بزنید تا حساب اولیه ساخته شود:
```bash
cd /var/www/crm/apps/api
npx prisma db seed
```
اگر حساب از قبل وجود داشته باشد، seed آن را عوض نمی‌کند. برای رمز جدید از همان پنل یا به‌روزرسانی دستی دیتابیس استفاده کنید.
اگر حساب با همان موبایل از قبل باشد، seed آن را عوض نمی‌کند. ادمین‌های بعدی را از داخل پنل (`/adm/admins`) اضافه کنید.
3. ورود: `https://crm.montzar.ir/adm` → داشبورد `/adm/dashboard`
توکن سوپرادمین جدا از JWT کاربران سازمانی است؛ با حساب CRM معمولی نمی‌توان وارد `/adm` شد.
توکن سوپرادمین جدا از JWT کاربران سازمانی است.
---
+17 -2
View File
@@ -2517,12 +2517,16 @@ export type UpdateMessageAutomationRuleRequest =
export interface PlatformAdminDto {
id: string;
email: string;
mobile?: string | null;
email?: string | null;
fullName: string;
isActive?: boolean;
lastLoginAt?: string | null;
createdAt?: string;
}
export interface PlatformLoginRequest {
email: string;
mobile: string;
password: string;
}
@@ -2531,6 +2535,17 @@ export interface PlatformLoginResponse {
admin: PlatformAdminDto;
}
export interface CreatePlatformAdminRequest {
fullName: string;
mobile: string;
password: string;
email?: string;
}
export interface ResetPlatformAdminPasswordRequest {
password: string;
}
export interface PlatformStatsDto {
organizationCount: number;
activeOrganizationCount: number;