Edit login suoerAdmin
This commit is contained in:
+5
-3
@@ -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");
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
@@ -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 کاربران سازمانی است.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user