Creating a CRM Super Admin
This commit is contained in:
@@ -24,4 +24,10 @@ SMS_SENDER=
|
||||
# مثال ساخت: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
INTEGRATIONS_ENCRYPTION_KEY=
|
||||
|
||||
# سوپرادمین پلتفرم — ورود از /adm
|
||||
# در لوکال اگر خالی بماند: platform@demo.local / demo1234
|
||||
PLATFORM_ADMIN_EMAIL=
|
||||
PLATFORM_ADMIN_PASSWORD=
|
||||
PLATFORM_ADMIN_NAME=سوپرادمین
|
||||
|
||||
# Production: see deploy/.env.production.example and deploy/DEPLOY.md
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN IF NOT EXISTS "isActive" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "PlatformAdmin" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"fullName" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"lastLoginAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PlatformAdmin_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_email_key" ON "PlatformAdmin"("email");
|
||||
@@ -147,6 +147,7 @@ model Organization {
|
||||
businessField String?
|
||||
baseCurrency String @default("IRR")
|
||||
enabledCurrencies String[] @default(["IRR", "USD", "EUR"])
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
users User[]
|
||||
@@ -278,6 +279,18 @@ model Membership {
|
||||
@@index([orgRoleId])
|
||||
}
|
||||
|
||||
/// حساب سوپرادمین پلتفرم — جدا از User سازمانی
|
||||
model PlatformAdmin {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
fullName String
|
||||
isActive Boolean @default(true)
|
||||
lastLoginAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Company {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
|
||||
@@ -13,7 +13,41 @@ const DEFAULT_STAGES = [
|
||||
{ name: 'از دسترفته', isWon: false, isLost: true },
|
||||
] as const;
|
||||
|
||||
async function seedPlatformAdmin() {
|
||||
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 password = envPassword || (isProd ? '' : 'demo1234');
|
||||
|
||||
if (!email || !password) {
|
||||
console.log(
|
||||
'Platform admin seed skipped (set PLATFORM_ADMIN_EMAIL and PLATFORM_ADMIN_PASSWORD)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
console.log('Platform admin seed skipped (password must be at least 8 chars)');
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await prisma.platformAdmin.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
console.log('Platform admin exists:', email);
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await prisma.platformAdmin.create({
|
||||
data: { email, passwordHash, fullName: envName },
|
||||
});
|
||||
console.log('Seeded platform admin:', email);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await seedPlatformAdmin();
|
||||
|
||||
const email = 'owner@demo.local';
|
||||
let org = await prisma.organization.findUnique({ where: { slug: 'demo-mento' } });
|
||||
if (!org) {
|
||||
|
||||
@@ -32,6 +32,7 @@ import { MemoryModule } from './memory/memory.module';
|
||||
import { TahesabModule } from './integrations/tahesab/tahesab.module';
|
||||
import { InventoryModule } from './inventory/inventory.module';
|
||||
import { FinanceModule } from './finance/finance.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -45,6 +46,7 @@ import { FinanceModule } from './finance/finance.module';
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
PlatformModule,
|
||||
UsersModule,
|
||||
OrgRolesModule,
|
||||
OrganizationsModule,
|
||||
|
||||
@@ -24,7 +24,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
|
||||
if (payload.typ === 'portal' || payload.typ === 'org-select') {
|
||||
if (
|
||||
payload.typ === 'portal' ||
|
||||
payload.typ === 'org-select' ||
|
||||
payload.typ === 'platform-admin'
|
||||
) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
if (!payload.sub || !payload.organizationId) {
|
||||
@@ -42,6 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
userId: user.id,
|
||||
organizationId: payload.organizationId,
|
||||
isActive: true,
|
||||
organization: { isActive: true },
|
||||
},
|
||||
select: { organizationId: true, role: true },
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export class MembershipsService {
|
||||
|
||||
async listActive(userId: string) {
|
||||
return this.prisma.membership.findMany({
|
||||
where: { userId, isActive: true },
|
||||
where: { userId, isActive: true, organization: { isActive: true } },
|
||||
include: {
|
||||
organization: { select: { id: true, name: true } },
|
||||
orgRole: { select: { name: true } },
|
||||
@@ -35,12 +35,24 @@ export class MembershipsService {
|
||||
): Promise<MembershipActor> {
|
||||
const row = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
select: { userId: true, organizationId: true, role: true },
|
||||
select: {
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
role: true,
|
||||
organization: { select: { isActive: true } },
|
||||
},
|
||||
});
|
||||
if (!row) {
|
||||
throw new ForbiddenException('به این محیط دسترسی ندارید');
|
||||
}
|
||||
return row;
|
||||
if (row.organization.isActive === false) {
|
||||
throw new UnauthorizedException('این محیط غیرفعال است');
|
||||
}
|
||||
return {
|
||||
userId: row.userId,
|
||||
organizationId: row.organizationId,
|
||||
role: row.role,
|
||||
};
|
||||
}
|
||||
|
||||
toChoices(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { USER_ROLES, type UserRole } from '@mento/shared';
|
||||
|
||||
export class PlatformLoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class UpdatePlatformStatusDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export class CreatePlatformOrganizationDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
businessField?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
ownerFullName!: string;
|
||||
|
||||
@IsEmail()
|
||||
ownerEmail!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(120)
|
||||
ownerPassword!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
ownerMobile?: string;
|
||||
}
|
||||
|
||||
export class UpdatePlatformOrganizationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(64)
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null && v !== undefined)
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
businessField?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdatePlatformMembershipRoleDto {
|
||||
@IsIn(USER_ROLES)
|
||||
role!: UserRole;
|
||||
}
|
||||
|
||||
export class ResetPlatformUserPasswordDto {
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(120)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { PlatformLoginDto } from './dto';
|
||||
import { PlatformAuthGuard } from './platform-auth.guard';
|
||||
import { PlatformAuthService } from './platform-auth.service';
|
||||
import type { PlatformActor } from './platform-jwt.strategy';
|
||||
|
||||
@Controller('platform/auth')
|
||||
export class PlatformAuthController {
|
||||
constructor(private readonly auth: PlatformAuthService) {}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: PlatformLoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(PlatformAuthGuard)
|
||||
me(@CurrentUser() actor: PlatformActor) {
|
||||
return this.auth.me(actor.platformAdminId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformAuthGuard extends AuthGuard('platform-jwt') {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PlatformLoginDto } from './dto';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformAuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
async login(dto: PlatformLoginDto): Promise<PlatformLoginResponse> {
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const admin = await this.prisma.platformAdmin.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
if (!admin || admin.isActive === false) {
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
const ok = await bcrypt.compare(dto.password, admin.passwordHash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
await this.prisma.platformAdmin.update({
|
||||
where: { id: admin.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
return {
|
||||
accessToken: this.jwt.sign(
|
||||
{ sub: admin.id, typ: 'platform-admin' },
|
||||
{ expiresIn: '12h' },
|
||||
),
|
||||
admin: this.toDto(admin),
|
||||
};
|
||||
}
|
||||
|
||||
async me(platformAdminId: string): Promise<PlatformAdminDto> {
|
||||
const admin = await this.prisma.platformAdmin.findUnique({
|
||||
where: { id: platformAdminId },
|
||||
});
|
||||
if (!admin || admin.isActive === false) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return this.toDto(admin);
|
||||
}
|
||||
|
||||
private toDto(admin: {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
}): PlatformAdminDto {
|
||||
return {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
fullName: admin.fullName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type PlatformJwtPayload = {
|
||||
sub: string;
|
||||
typ?: string;
|
||||
};
|
||||
|
||||
export type PlatformActor = {
|
||||
platformAdminId: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PlatformJwtStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
'platform-jwt',
|
||||
) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get<string>('JWT_SECRET') ?? 'dev-secret',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: PlatformJwtPayload): Promise<PlatformActor> {
|
||||
if (payload.typ !== 'platform-admin' || !payload.sub) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const admin = await this.prisma.platformAdmin.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
fullName: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!admin || admin.isActive === false) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
return {
|
||||
platformAdminId: admin.id,
|
||||
email: admin.email,
|
||||
fullName: admin.fullName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CreatePlatformOrganizationDto,
|
||||
ResetPlatformUserPasswordDto,
|
||||
UpdatePlatformMembershipRoleDto,
|
||||
UpdatePlatformOrganizationDto,
|
||||
UpdatePlatformStatusDto,
|
||||
} from './dto';
|
||||
import { PlatformAuthGuard } from './platform-auth.guard';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Controller('platform')
|
||||
@UseGuards(PlatformAuthGuard)
|
||||
export class PlatformController {
|
||||
constructor(private readonly platform: PlatformService) {}
|
||||
|
||||
@Get('stats')
|
||||
stats() {
|
||||
return this.platform.stats();
|
||||
}
|
||||
|
||||
@Get('organizations')
|
||||
listOrganizations(@Query('q') q?: string) {
|
||||
return this.platform.listOrganizations(q);
|
||||
}
|
||||
|
||||
@Post('organizations')
|
||||
createOrganization(@Body() dto: CreatePlatformOrganizationDto) {
|
||||
return this.platform.createOrganization(dto);
|
||||
}
|
||||
|
||||
@Get('organizations/:id')
|
||||
getOrganization(@Param('id') id: string) {
|
||||
return this.platform.getOrganization(id);
|
||||
}
|
||||
|
||||
@Patch('organizations/:id')
|
||||
updateOrganization(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePlatformOrganizationDto,
|
||||
) {
|
||||
return this.platform.updateOrganization(id, dto);
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
listUsers(@Query('q') q?: string) {
|
||||
return this.platform.listUsers(q);
|
||||
}
|
||||
|
||||
@Get('users/:id')
|
||||
getUser(@Param('id') id: string) {
|
||||
return this.platform.getUser(id);
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
setUserActive(@Param('id') id: string, @Body() dto: UpdatePlatformStatusDto) {
|
||||
return this.platform.setUserActive(id, dto.isActive);
|
||||
}
|
||||
|
||||
@Patch('users/:id/password')
|
||||
resetUserPassword(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ResetPlatformUserPasswordDto,
|
||||
) {
|
||||
return this.platform.resetUserPassword(id, dto);
|
||||
}
|
||||
|
||||
@Patch('memberships/:id')
|
||||
setMembershipActive(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePlatformStatusDto,
|
||||
) {
|
||||
return this.platform.setMembershipActive(id, dto.isActive);
|
||||
}
|
||||
|
||||
@Patch('memberships/:id/role')
|
||||
updateMembershipRole(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePlatformMembershipRoleDto,
|
||||
) {
|
||||
return this.platform.updateMembershipRole(id, dto.role);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { MembershipsModule } from '../memberships/memberships.module';
|
||||
import { OrgRolesModule } from '../org-roles/org-roles.module';
|
||||
import { PipelinesModule } from '../pipelines/pipelines.module';
|
||||
import { PlatformAuthController } from './platform-auth.controller';
|
||||
import { PlatformAuthGuard } from './platform-auth.guard';
|
||||
import { PlatformAuthService } from './platform-auth.service';
|
||||
import { PlatformJwtStrategy } from './platform-jwt.strategy';
|
||||
import { PlatformController } from './platform.controller';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
MembershipsModule,
|
||||
OrgRolesModule,
|
||||
PipelinesModule,
|
||||
PassportModule,
|
||||
],
|
||||
controllers: [PlatformAuthController, PlatformController],
|
||||
providers: [
|
||||
PlatformJwtStrategy,
|
||||
PlatformAuthGuard,
|
||||
PlatformAuthService,
|
||||
PlatformService,
|
||||
],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
@@ -0,0 +1,530 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
PlatformOrganizationDto,
|
||||
PlatformStatsDto,
|
||||
PlatformUserDto,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { isBusinessField } from '@mento/shared';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { normalizeMobile } from '../auth/auth.service';
|
||||
import { MembershipsService } from '../memberships/memberships.service';
|
||||
import { OrgRolesService } from '../org-roles/org-roles.service';
|
||||
import { PipelinesService } from '../pipelines/pipelines.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type {
|
||||
CreatePlatformOrganizationDto,
|
||||
ResetPlatformUserPasswordDto,
|
||||
UpdatePlatformOrganizationDto,
|
||||
} from './dto';
|
||||
|
||||
function slugify(input: string): string {
|
||||
return (
|
||||
input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u0600-\u06FF]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48) || `org-${Date.now()}`
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlatformService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly memberships: MembershipsService,
|
||||
private readonly orgRoles: OrgRolesService,
|
||||
private readonly pipelines: PipelinesService,
|
||||
) {}
|
||||
|
||||
async stats(): Promise<PlatformStatsDto> {
|
||||
const [
|
||||
organizationCount,
|
||||
activeOrganizationCount,
|
||||
userCount,
|
||||
activeUserCount,
|
||||
membershipCount,
|
||||
] = await this.prisma.$transaction([
|
||||
this.prisma.organization.count(),
|
||||
this.prisma.organization.count({ where: { isActive: true } }),
|
||||
this.prisma.user.count(),
|
||||
this.prisma.user.count({ where: { isActive: true } }),
|
||||
this.prisma.membership.count(),
|
||||
]);
|
||||
return {
|
||||
organizationCount,
|
||||
activeOrganizationCount,
|
||||
userCount,
|
||||
activeUserCount,
|
||||
membershipCount,
|
||||
};
|
||||
}
|
||||
|
||||
async listOrganizations(query?: string): Promise<PlatformOrganizationDto[]> {
|
||||
const q = query?.trim();
|
||||
const rows = await this.prisma.organization.findMany({
|
||||
where: q
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: q, mode: 'insensitive' } },
|
||||
{ slug: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
businessField: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
memberships: true,
|
||||
people: true,
|
||||
deals: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return rows.map((row) => this.orgToDto(row));
|
||||
}
|
||||
|
||||
async getOrganization(id: string): Promise<PlatformOrganizationDto> {
|
||||
const row = await this.prisma.organization.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
businessField: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
memberships: true,
|
||||
people: true,
|
||||
deals: true,
|
||||
},
|
||||
},
|
||||
memberships: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
fullName: true,
|
||||
email: true,
|
||||
mobile: true,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('محیط پیدا نشد');
|
||||
return {
|
||||
...this.orgToDto(row),
|
||||
members: row.memberships.map((m) => ({
|
||||
membershipId: m.id,
|
||||
userId: m.user.id,
|
||||
fullName: m.user.fullName,
|
||||
email: m.user.email,
|
||||
mobile: m.user.mobile,
|
||||
role: m.role,
|
||||
isActive: m.isActive,
|
||||
userIsActive: m.user.isActive,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createOrganization(
|
||||
dto: CreatePlatformOrganizationDto,
|
||||
): Promise<PlatformOrganizationDto> {
|
||||
const name = dto.name.trim();
|
||||
if (name.length < 2) {
|
||||
throw new BadRequestException('نام محیط معتبر نیست');
|
||||
}
|
||||
const ownerFullName = dto.ownerFullName.trim();
|
||||
if (ownerFullName.length < 2) {
|
||||
throw new BadRequestException('نام مالک معتبر نیست');
|
||||
}
|
||||
const ownerEmail = dto.ownerEmail.trim().toLowerCase();
|
||||
const ownerPassword = dto.ownerPassword.trim();
|
||||
if (ownerPassword.length < 8) {
|
||||
throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است');
|
||||
}
|
||||
|
||||
const businessField =
|
||||
dto.businessField && isBusinessField(dto.businessField)
|
||||
? dto.businessField
|
||||
: 'personal';
|
||||
|
||||
let ownerMobile: string | null = null;
|
||||
if (dto.ownerMobile?.trim()) {
|
||||
ownerMobile = normalizeMobile(dto.ownerMobile);
|
||||
if (!ownerMobile) {
|
||||
throw new BadRequestException('شماره موبایل مالک معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
const existingEmail = await this.prisma.user.findFirst({
|
||||
where: { email: ownerEmail },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingEmail) {
|
||||
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
|
||||
}
|
||||
if (ownerMobile) {
|
||||
const existingMobile = await this.prisma.user.findFirst({
|
||||
where: { mobile: ownerMobile },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingMobile) {
|
||||
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
|
||||
const baseSlug = slugify(name);
|
||||
let slug = baseSlug;
|
||||
let i = 1;
|
||||
while (await this.prisma.organization.findUnique({ where: { slug } })) {
|
||||
slug = `${baseSlug}-${i++}`;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(ownerPassword, 10);
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const organization = await this.prisma.organization.create({
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
businessField,
|
||||
users: {
|
||||
create: {
|
||||
email: ownerEmail,
|
||||
mobile: ownerMobile,
|
||||
fullName: ownerFullName,
|
||||
passwordHash,
|
||||
role: 'owner',
|
||||
emailVerifiedAt: now,
|
||||
mobileVerifiedAt: ownerMobile ? now : null,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { users: true },
|
||||
});
|
||||
|
||||
const owner = organization.users[0];
|
||||
if (!owner) {
|
||||
throw new BadRequestException('ساخت حساب مالک ناموفق بود');
|
||||
}
|
||||
|
||||
await this.memberships.createMembership({
|
||||
userId: owner.id,
|
||||
organizationId: organization.id,
|
||||
role: 'owner',
|
||||
});
|
||||
await this.orgRoles.ensureDefaultRoles(organization.id);
|
||||
await this.pipelines.ensureDefaultPipeline(organization.id);
|
||||
|
||||
return this.getOrganization(organization.id);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('ایمیل یا موبایل تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrganization(
|
||||
id: string,
|
||||
dto: UpdatePlatformOrganizationDto,
|
||||
): Promise<PlatformOrganizationDto> {
|
||||
const existing = await this.prisma.organization.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('محیط پیدا نشد');
|
||||
|
||||
const data: Prisma.OrganizationUpdateInput = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (name.length < 2) {
|
||||
throw new BadRequestException('نام محیط معتبر نیست');
|
||||
}
|
||||
data.name = name;
|
||||
}
|
||||
|
||||
if (dto.slug !== undefined) {
|
||||
const slug = slugify(dto.slug);
|
||||
if (slug.length < 2) {
|
||||
throw new BadRequestException('اسلاگ معتبر نیست');
|
||||
}
|
||||
if (slug !== existing.slug) {
|
||||
const clash = await this.prisma.organization.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
if (clash) {
|
||||
throw new ConflictException('این اسلاگ قبلاً استفاده شده است');
|
||||
}
|
||||
data.slug = slug;
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.businessField !== undefined) {
|
||||
if (dto.businessField === null || dto.businessField === '') {
|
||||
data.businessField = null;
|
||||
} else if (!isBusinessField(dto.businessField)) {
|
||||
throw new BadRequestException('زمینه فعالیت معتبر نیست');
|
||||
} else {
|
||||
data.businessField = dto.businessField;
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.isActive !== undefined) {
|
||||
data.isActive = dto.isActive;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
throw new BadRequestException('هیچ فیلدی برای بهروزرسانی ارسال نشده');
|
||||
}
|
||||
|
||||
await this.prisma.organization.update({ where: { id }, data });
|
||||
return this.getOrganization(id);
|
||||
}
|
||||
|
||||
async setOrganizationActive(
|
||||
id: string,
|
||||
isActive: boolean,
|
||||
): Promise<PlatformOrganizationDto> {
|
||||
return this.updateOrganization(id, { isActive });
|
||||
}
|
||||
|
||||
async listUsers(query?: string): Promise<PlatformUserDto[]> {
|
||||
const q = query?.trim();
|
||||
const rows = await this.prisma.user.findMany({
|
||||
where: q
|
||||
? {
|
||||
OR: [
|
||||
{ fullName: { contains: q, mode: 'insensitive' } },
|
||||
{ email: { contains: q, mode: 'insensitive' } },
|
||||
{ mobile: { contains: q } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 300,
|
||||
include: {
|
||||
memberships: {
|
||||
include: {
|
||||
organization: {
|
||||
select: { id: true, name: true, isActive: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
return rows.map((row) => this.userToDto(row));
|
||||
}
|
||||
|
||||
async getUser(id: string): Promise<PlatformUserDto> {
|
||||
const row = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
memberships: {
|
||||
include: {
|
||||
organization: {
|
||||
select: { id: true, name: true, isActive: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('کاربر پیدا نشد');
|
||||
return this.userToDto(row);
|
||||
}
|
||||
|
||||
async setUserActive(id: string, isActive: boolean): Promise<PlatformUserDto> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('کاربر پیدا نشد');
|
||||
await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
});
|
||||
return this.getUser(id);
|
||||
}
|
||||
|
||||
async resetUserPassword(
|
||||
id: string,
|
||||
dto: ResetPlatformUserPasswordDto,
|
||||
): Promise<{ ok: true }> {
|
||||
const existing = await this.prisma.user.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.user.update({
|
||||
where: { id },
|
||||
data: { passwordHash },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async setMembershipActive(
|
||||
id: string,
|
||||
isActive: boolean,
|
||||
): Promise<PlatformUserDto> {
|
||||
const row = await this.prisma.membership.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
organizationId: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
if (!isActive && row.role === 'owner') {
|
||||
const activeOwners = await this.memberships.countActiveOwners(
|
||||
row.organizationId,
|
||||
row.userId,
|
||||
);
|
||||
if (activeOwners < 1) {
|
||||
throw new BadRequestException('حداقل یک مالک فعال برای این محیط لازم است');
|
||||
}
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
});
|
||||
return this.getUser(row.userId);
|
||||
}
|
||||
|
||||
async updateMembershipRole(
|
||||
id: string,
|
||||
role: UserRole,
|
||||
): Promise<PlatformUserDto> {
|
||||
const row = await this.prisma.membership.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: { select: { id: true, organizationId: true } },
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('عضویت پیدا نشد');
|
||||
|
||||
if (row.role === 'owner' && role !== 'owner') {
|
||||
const owners = await this.memberships.countActiveOwners(
|
||||
row.organizationId,
|
||||
row.userId,
|
||||
);
|
||||
if (owners < 1) {
|
||||
throw new BadRequestException(
|
||||
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.orgRoles.ensureDefaultRoles(row.organizationId);
|
||||
const orgRole = await this.prisma.orgRole.findFirst({
|
||||
where: { organizationId: row.organizationId, systemKey: role },
|
||||
});
|
||||
|
||||
await this.prisma.membership.update({
|
||||
where: { id },
|
||||
data: {
|
||||
role,
|
||||
orgRoleId: orgRole?.id ?? row.orgRoleId,
|
||||
},
|
||||
});
|
||||
|
||||
if (row.user.organizationId === row.organizationId) {
|
||||
await this.memberships.syncUserLastOrg(
|
||||
row.userId,
|
||||
row.organizationId,
|
||||
role,
|
||||
orgRole?.id ?? row.orgRoleId,
|
||||
);
|
||||
}
|
||||
|
||||
return this.getUser(row.userId);
|
||||
}
|
||||
|
||||
private orgToDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
businessField: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
_count: { memberships: number; people: number; deals: number };
|
||||
}): PlatformOrganizationDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
businessField: row.businessField,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
memberCount: row._count.memberships,
|
||||
personCount: row._count.people,
|
||||
dealCount: row._count.deals,
|
||||
};
|
||||
}
|
||||
|
||||
private userToDto(row: {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile: string | null;
|
||||
fullName: string;
|
||||
isActive: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
createdAt: Date;
|
||||
memberships: Array<{
|
||||
id: string;
|
||||
role: PlatformUserDto['memberships'][number]['role'];
|
||||
isActive: boolean;
|
||||
organization: { id: string; name: string; isActive: boolean };
|
||||
}>;
|
||||
}): PlatformUserDto {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
mobile: row.mobile,
|
||||
fullName: row.fullName,
|
||||
isActive: row.isActive,
|
||||
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
memberships: row.memberships.map((m) => ({
|
||||
membershipId: m.id,
|
||||
organizationId: m.organization.id,
|
||||
organizationName: m.organization.name,
|
||||
organizationIsActive: m.organization.isActive,
|
||||
role: m.role,
|
||||
isActive: m.isActive,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink, RouterView, useRouter } from 'vue-router';
|
||||
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import BrandName from '@/components/brand/BrandName.vue';
|
||||
|
||||
const platform = usePlatformAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const displayName = computed(() => platform.admin?.fullName || 'سوپرادمین');
|
||||
|
||||
function onLogout() {
|
||||
platform.logout();
|
||||
void router.push({ name: 'adm-login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="adm-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<MentoLogo :size="32" :show-text="false" />
|
||||
<div class="brand-copy">
|
||||
<BrandName />
|
||||
<span>پنل سوپرادمین</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<RouterLink :to="{ name: 'adm-dashboard' }" class="nav-item">
|
||||
<UiIcon name="home" :size="18" />
|
||||
<span>داشبورد</span>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'adm-organizations' }" class="nav-item">
|
||||
<UiIcon name="building" :size="18" />
|
||||
<span>محیطها</span>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'adm-users' }" class="nav-item">
|
||||
<UiIcon name="users" :size="18" />
|
||||
<span>کاربران</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="side-foot">
|
||||
<p class="who">{{ displayName }}</p>
|
||||
<button type="button" class="logout" @click="onLogout">
|
||||
<UiIcon name="logout" :size="16" />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.adm-shell {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.2rem;
|
||||
padding: 1.15rem 0.9rem;
|
||||
background: #fff;
|
||||
border-inline-end: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.2rem 0.35rem;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-copy span {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
color: var(--ink-secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-item.router-link-active {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.side-foot {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.who {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logout {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
padding: 1.4rem 1.5rem 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.adm-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-inline-end: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.side-foot {
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem 0.9rem 1.6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -26,7 +26,7 @@ api.interceptors.response.use(
|
||||
(error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const path = window.location.pathname;
|
||||
if (path.startsWith('/p/')) {
|
||||
if (path.startsWith('/p/') || path.startsWith('/adm')) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
localStorage.removeItem('mento_token');
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export const platformApi = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
platformApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('mento_platform_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
platformApi.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||
const path = window.location.pathname;
|
||||
localStorage.removeItem('mento_platform_token');
|
||||
localStorage.removeItem('mento_platform_admin');
|
||||
if (path.startsWith('/adm') && path !== '/adm') {
|
||||
window.location.assign('/adm');
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import type { UserRole } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -29,6 +30,34 @@ export const router = createRouter({
|
||||
component: () => import('@/views/PortalView.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: '/adm',
|
||||
name: 'adm-login',
|
||||
component: () => import('@/views/adm/AdmLoginView.vue'),
|
||||
meta: { public: true, platformPublic: true },
|
||||
},
|
||||
{
|
||||
path: '/adm',
|
||||
component: () => import('@/layouts/AdmLayout.vue'),
|
||||
meta: { public: true, platform: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'adm-dashboard',
|
||||
component: () => import('@/views/adm/AdmDashboardView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'organizations',
|
||||
name: 'adm-organizations',
|
||||
component: () => import('@/views/adm/AdmOrganizationsView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'adm-users',
|
||||
component: () => import('@/views/adm/AdmUsersView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/AppLayout.vue'),
|
||||
@@ -291,6 +320,16 @@ export const router = createRouter({
|
||||
});
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.path === '/adm' || to.path.startsWith('/adm/')) {
|
||||
const platform = usePlatformAuthStore();
|
||||
if (to.meta.platformPublic) {
|
||||
if (platform.isAuthenticated) return { name: 'adm-dashboard' };
|
||||
return true;
|
||||
}
|
||||
if (!platform.isAuthenticated) return { name: 'adm-login' };
|
||||
return true;
|
||||
}
|
||||
|
||||
const auth = useAuthStore();
|
||||
if (!to.meta.public && !auth.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } };
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared';
|
||||
import { platformApi } from '@/lib/platform-api';
|
||||
|
||||
const TOKEN_KEY = 'mento_platform_token';
|
||||
const ADMIN_KEY = 'mento_platform_admin';
|
||||
|
||||
function readStoredAdmin(): PlatformAdminDto | null {
|
||||
const raw = localStorage.getItem(ADMIN_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as PlatformAdminDto;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const usePlatformAuthStore = defineStore('platform-auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY));
|
||||
const admin = ref<PlatformAdminDto | null>(readStoredAdmin());
|
||||
const isAuthenticated = computed(() => Boolean(token.value));
|
||||
|
||||
function persist(payload: PlatformLoginResponse) {
|
||||
token.value = payload.accessToken;
|
||||
admin.value = payload.admin;
|
||||
localStorage.setItem(TOKEN_KEY, payload.accessToken);
|
||||
localStorage.setItem(ADMIN_KEY, JSON.stringify(payload.admin));
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const { data } = await platformApi.post<PlatformLoginResponse>(
|
||||
'/platform/auth/login',
|
||||
{ email, password },
|
||||
);
|
||||
persist(data);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null;
|
||||
admin.value = null;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(ADMIN_KEY);
|
||||
}
|
||||
|
||||
async function hydrate() {
|
||||
if (!token.value) return;
|
||||
try {
|
||||
const { data } = await platformApi.get<PlatformAdminDto>('/platform/auth/me');
|
||||
admin.value = data;
|
||||
localStorage.setItem(ADMIN_KEY, JSON.stringify(data));
|
||||
} catch {
|
||||
logout();
|
||||
}
|
||||
}
|
||||
|
||||
if (token.value) void hydrate();
|
||||
|
||||
return {
|
||||
token,
|
||||
admin,
|
||||
isAuthenticated,
|
||||
login,
|
||||
logout,
|
||||
hydrate,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import type { PlatformStatsDto } 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';
|
||||
|
||||
const platform = usePlatformAuthStore();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const stats = ref<PlatformStatsDto | null>(null);
|
||||
|
||||
async function load() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await platformApi.get<PlatformStatsDto>('/platform/stats');
|
||||
stats.value = data;
|
||||
} catch (e) {
|
||||
error.value = axios.isAxiosError(e)
|
||||
? 'بارگذاری آمار ناموفق بود'
|
||||
: 'بارگذاری آمار ناموفق بود';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<div>
|
||||
<p class="eyebrow">سوپرادمین</p>
|
||||
<h1>سلام {{ platform.admin?.fullName || '' }}</h1>
|
||||
<p class="lead">نمای کلی حسابها و محیطهای ثبتشده روی پلتفرم.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
|
||||
<div v-else-if="stats" class="kpis">
|
||||
<article class="kpi">
|
||||
<span class="ico"><UiIcon name="building" :size="20" /></span>
|
||||
<p>محیطها</p>
|
||||
<strong>{{ toFaDigits(stats.organizationCount) }}</strong>
|
||||
<small>{{ toFaDigits(stats.activeOrganizationCount) }} فعال</small>
|
||||
</article>
|
||||
<article class="kpi">
|
||||
<span class="ico"><UiIcon name="users" :size="20" /></span>
|
||||
<p>کاربران</p>
|
||||
<strong>{{ toFaDigits(stats.userCount) }}</strong>
|
||||
<small>{{ toFaDigits(stats.activeUserCount) }} فعال</small>
|
||||
</article>
|
||||
<article class="kpi">
|
||||
<span class="ico"><UiIcon name="layers" :size="20" /></span>
|
||||
<p>عضویتها</p>
|
||||
<strong>{{ toFaDigits(stats.membershipCount) }}</strong>
|
||||
<small>نقش در محیطها</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="shortcuts">
|
||||
<RouterLink :to="{ name: 'adm-organizations' }" class="card">
|
||||
<UiIcon name="building" :size="18" />
|
||||
<div>
|
||||
<strong>مدیریت محیطها</strong>
|
||||
<span>ساخت، ویرایش و فعال/غیرفعال کردن سازمانها</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{ name: 'adm-users' }" class="card">
|
||||
<UiIcon name="users" :size="18" />
|
||||
<div>
|
||||
<strong>مدیریت کاربران</strong>
|
||||
<span>نقش، دسترسی و ریست رمز عبور</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1.2rem;
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0.15rem 0 0.35rem;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent-deep);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.lead,
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 14px;
|
||||
padding: 1rem 1.05rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.kpi p,
|
||||
.kpi small {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.kpi strong {
|
||||
display: block;
|
||||
margin: 0.2rem 0 0.15rem;
|
||||
font-size: 1.55rem;
|
||||
}
|
||||
|
||||
.ico {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 0.55rem;
|
||||
border-radius: 10px;
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 1rem 1.05rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.kpis,
|
||||
.shortcuts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { usePlatformAuthStore } from '@/stores/platform-auth';
|
||||
import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue';
|
||||
import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import BrandName from '@/components/brand/BrandName.vue';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
const platform = usePlatformAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const showPassword = ref(false);
|
||||
const error = ref('');
|
||||
const loading = ref(false);
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (!e.response) {
|
||||
return 'اتصال به سرور برقرار نشد.';
|
||||
}
|
||||
const msg = (e.response.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = '';
|
||||
loading.value = true;
|
||||
try {
|
||||
await platform.login(email.value.trim(), password.value);
|
||||
await router.push({ name: 'adm-dashboard' });
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ورود ناموفق بود');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-shell">
|
||||
<AuthBrandPanel
|
||||
headline="کنترل حسابها و دسترسیها"
|
||||
description="پنل سوپرادمین برای مدیریت محیطها، کاربران و وضعیت دسترسیها."
|
||||
/>
|
||||
|
||||
<section class="form-panel">
|
||||
<header class="mobile-brand">
|
||||
<MentoLogo :size="52" :show-text="false" />
|
||||
<BrandName />
|
||||
<span>پنل سوپرادمین</span>
|
||||
</header>
|
||||
|
||||
<div class="form-inner">
|
||||
<h1>ورود سوپرادمین</h1>
|
||||
<p class="lead">فقط حساب پلتفرم میتواند وارد این بخش شود.</p>
|
||||
|
||||
<form class="form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="adm-email">ایمیل</label>
|
||||
<div class="input-wrap">
|
||||
<input
|
||||
id="adm-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="username"
|
||||
placeholder="ایمیل سوپرادمین را وارد کنید"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="mail" :size="18" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="adm-password">رمز عبور</label>
|
||||
<div class="input-wrap has-eye">
|
||||
<input
|
||||
id="adm-password"
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="رمز عبور را وارد کنید"
|
||||
class="ltr-value"
|
||||
/>
|
||||
<span class="field-ico" aria-hidden="true">
|
||||
<UiIcon name="lock" :size="18" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="eye"
|
||||
:aria-label="showPassword ? 'مخفی کردن رمز' : 'نمایش رمز'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<UiIcon :name="showPassword ? 'eye-off' : 'eye'" :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<button class="btn submit" type="submit" :disabled="loading">
|
||||
{{ loading ? 'در حال ورود…' : 'ورود به پنل' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="auth-footer">
|
||||
<p>۱۴۰۵ {{ brand.nameFa }}. تمامی حقوق محفوظ است.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
padding-bottom: max(1rem, env(safe-area-inset-bottom));
|
||||
background:
|
||||
radial-gradient(ellipse 70% 45% at 50% 0%, rgba(45, 212, 191, 0.12), transparent 55%),
|
||||
radial-gradient(rgba(13, 148, 136, 0.12) 1px, transparent 1.2px),
|
||||
#f5fbfb;
|
||||
background-size: auto, 20px 20px, auto;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
flex: 1;
|
||||
width: min(1100px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
min-height: min(680px, calc(100dvh - 5.5rem));
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: clamp(1.2rem, 3vw, 2.4rem);
|
||||
min-width: 0;
|
||||
background: #fff;
|
||||
border-radius: 28px;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1.4rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.mobile-brand span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.form-inner {
|
||||
width: min(420px, 100%);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: clamp(1.45rem, 3vw, 1.85rem);
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 1.2rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.form input {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.form input::placeholder {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
color: #cdd4de;
|
||||
}
|
||||
|
||||
.form input.ltr-value:not(:placeholder-shown) {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-wrap input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.85rem 2.6rem 0.85rem 0.95rem;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.input-wrap.has-eye input {
|
||||
padding-left: 2.7rem;
|
||||
}
|
||||
|
||||
.input-wrap input:focus {
|
||||
outline: 2px solid var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.field-ico {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.75rem;
|
||||
left: auto;
|
||||
transform: translateY(-50%);
|
||||
color: #94a3b8;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.eye {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.45rem;
|
||||
right: auto;
|
||||
transform: translateY(-50%);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
width: min(1040px, 100%);
|
||||
margin: 0.85rem auto 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.auth-page {
|
||||
padding: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
width: 100%;
|
||||
min-height: 100dvh;
|
||||
grid-template-columns: minmax(420px, 1fr) minmax(440px, 1.05fr);
|
||||
grid-template-areas: "form brand";
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
grid-area: form;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-shell :deep(.brand-panel) {
|
||||
grid-area: brand;
|
||||
height: 100dvh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.mobile-brand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-inner {
|
||||
width: min(440px, 100%);
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,611 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
CreatePlatformOrganizationRequest,
|
||||
PlatformOrganizationDto,
|
||||
UpdatePlatformOrganizationRequest,
|
||||
UserRole,
|
||||
} from '@mento/shared';
|
||||
import { USER_ROLE_LABELS, USER_ROLES, isBusinessField } from '@mento/shared';
|
||||
import { platformApi } from '@/lib/platform-api';
|
||||
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const query = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
const items = ref<PlatformOrganizationDto[]>([]);
|
||||
const openId = ref<string | null>(null);
|
||||
const detail = ref<PlatformOrganizationDto | null>(null);
|
||||
const busyId = ref('');
|
||||
|
||||
const showCreate = ref(false);
|
||||
const creating = ref(false);
|
||||
const createForm = reactive<CreatePlatformOrganizationRequest>({
|
||||
name: '',
|
||||
businessField: 'personal',
|
||||
ownerFullName: '',
|
||||
ownerEmail: '',
|
||||
ownerPassword: '',
|
||||
ownerMobile: '',
|
||||
});
|
||||
|
||||
const editingId = ref<string | null>(null);
|
||||
const savingEdit = ref(false);
|
||||
const editForm = reactive({
|
||||
name: '',
|
||||
slug: '',
|
||||
businessField: '' as string,
|
||||
});
|
||||
|
||||
function fieldLabel(value?: string | null) {
|
||||
if (!value) return '—';
|
||||
return isBusinessField(value) ? BUSINESS_FIELD_LABELS[value] : value;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function resetCreateForm() {
|
||||
createForm.name = '';
|
||||
createForm.businessField = 'personal';
|
||||
createForm.ownerFullName = '';
|
||||
createForm.ownerEmail = '';
|
||||
createForm.ownerPassword = '';
|
||||
createForm.ownerMobile = '';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await platformApi.get<PlatformOrganizationDto[]>(
|
||||
'/platform/organizations',
|
||||
{ params: query.value.trim() ? { q: query.value.trim() } : undefined },
|
||||
);
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری محیطها ناموفق بود');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOpen(org: PlatformOrganizationDto) {
|
||||
if (openId.value === org.id) {
|
||||
openId.value = null;
|
||||
detail.value = null;
|
||||
editingId.value = null;
|
||||
return;
|
||||
}
|
||||
openId.value = org.id;
|
||||
editingId.value = null;
|
||||
try {
|
||||
const { data } = await platformApi.get<PlatformOrganizationDto>(
|
||||
`/platform/organizations/${org.id}`,
|
||||
);
|
||||
detail.value = data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری اعضا ناموفق بود');
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(org: PlatformOrganizationDto) {
|
||||
editingId.value = org.id;
|
||||
editForm.name = org.name;
|
||||
editForm.slug = org.slug;
|
||||
editForm.businessField = org.businessField ?? '';
|
||||
success.value = '';
|
||||
error.value = '';
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
}
|
||||
|
||||
async function saveEdit(orgId: string) {
|
||||
savingEdit.value = true;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
try {
|
||||
const body: UpdatePlatformOrganizationRequest = {
|
||||
name: editForm.name.trim(),
|
||||
slug: editForm.slug.trim(),
|
||||
businessField: editForm.businessField || null,
|
||||
};
|
||||
const { data } = await platformApi.patch<PlatformOrganizationDto>(
|
||||
`/platform/organizations/${orgId}`,
|
||||
body,
|
||||
);
|
||||
items.value = items.value.map((row) =>
|
||||
row.id === orgId
|
||||
? {
|
||||
...row,
|
||||
name: data.name,
|
||||
slug: data.slug,
|
||||
businessField: data.businessField,
|
||||
isActive: data.isActive,
|
||||
}
|
||||
: row,
|
||||
);
|
||||
if (detail.value?.id === orgId) {
|
||||
detail.value = { ...detail.value, ...data };
|
||||
}
|
||||
editingId.value = null;
|
||||
success.value = 'اطلاعات محیط ذخیره شد';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره تغییرات ناموفق بود');
|
||||
} finally {
|
||||
savingEdit.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrganization() {
|
||||
creating.value = true;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
try {
|
||||
const body: CreatePlatformOrganizationRequest = {
|
||||
name: createForm.name.trim(),
|
||||
businessField: createForm.businessField || undefined,
|
||||
ownerFullName: createForm.ownerFullName.trim(),
|
||||
ownerEmail: createForm.ownerEmail.trim(),
|
||||
ownerPassword: createForm.ownerPassword,
|
||||
ownerMobile: createForm.ownerMobile?.trim() || undefined,
|
||||
};
|
||||
const { data } = await platformApi.post<PlatformOrganizationDto>(
|
||||
'/platform/organizations',
|
||||
body,
|
||||
);
|
||||
items.value = [data, ...items.value];
|
||||
showCreate.value = false;
|
||||
resetCreateForm();
|
||||
success.value = `محیط «${data.name}» ساخته شد`;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ساخت محیط ناموفق بود');
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setOrgActive(org: PlatformOrganizationDto, isActive: boolean) {
|
||||
busyId.value = org.id;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await platformApi.patch<PlatformOrganizationDto>(
|
||||
`/platform/organizations/${org.id}`,
|
||||
{ isActive },
|
||||
);
|
||||
items.value = items.value.map((row) => (row.id === org.id ? { ...row, isActive } : row));
|
||||
if (detail.value?.id === org.id) {
|
||||
detail.value = { ...detail.value, ...data };
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر وضعیت محیط ناموفق بود');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function setMembershipActive(membershipId: string, isActive: boolean) {
|
||||
busyId.value = membershipId;
|
||||
error.value = '';
|
||||
try {
|
||||
await platformApi.patch(`/platform/memberships/${membershipId}`, { isActive });
|
||||
if (openId.value) {
|
||||
const { data } = await platformApi.get<PlatformOrganizationDto>(
|
||||
`/platform/organizations/${openId.value}`,
|
||||
);
|
||||
detail.value = data;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر عضویت ناموفق بود');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function setMembershipRole(membershipId: string, role: UserRole) {
|
||||
busyId.value = `role-${membershipId}`;
|
||||
error.value = '';
|
||||
try {
|
||||
await platformApi.patch(`/platform/memberships/${membershipId}/role`, { role });
|
||||
if (openId.value) {
|
||||
const { data } = await platformApi.get<PlatformOrganizationDto>(
|
||||
`/platform/organizations/${openId.value}`,
|
||||
);
|
||||
detail.value = data;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر نقش ناموفق بود');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>محیطها</h1>
|
||||
<p>ساخت، ویرایش و فعال/غیرفعال کردن سازمانها و نقش اعضا.</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<form class="search" @submit.prevent="load">
|
||||
<input v-model="query" type="search" placeholder="جستجوی نام یا اسلاگ" />
|
||||
<button class="btn" type="submit" :disabled="loading">جستجو</button>
|
||||
</form>
|
||||
<button class="btn" type="button" @click="showCreate = !showCreate">
|
||||
{{ showCreate ? 'بستن فرم' : 'محیط جدید' }}
|
||||
</button>
|
||||
</div>
|
||||
</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="createOrganization">
|
||||
<h2>ساخت محیط جدید</h2>
|
||||
<div class="grid">
|
||||
<label>
|
||||
<span>نام محیط</span>
|
||||
<input v-model="createForm.name" required minlength="2" />
|
||||
</label>
|
||||
<label>
|
||||
<span>زمینه فعالیت</span>
|
||||
<select v-model="createForm.businessField">
|
||||
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
|
||||
{{ BUSINESS_FIELD_LABELS[field] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>نام مالک</span>
|
||||
<input v-model="createForm.ownerFullName" required minlength="2" />
|
||||
</label>
|
||||
<label>
|
||||
<span>ایمیل مالک</span>
|
||||
<input v-model="createForm.ownerEmail" type="email" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>رمز مالک</span>
|
||||
<input v-model="createForm.ownerPassword" type="password" required minlength="8" />
|
||||
</label>
|
||||
<label>
|
||||
<span>موبایل مالک (اختیاری)</span>
|
||||
<input v-model="createForm.ownerMobile" type="tel" 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="org in items" :key="org.id" class="row">
|
||||
<button type="button" class="row-main" @click="toggleOpen(org)">
|
||||
<div>
|
||||
<strong>{{ org.name }}</strong>
|
||||
<span>{{ org.slug }} · {{ fieldLabel(org.businessField) }}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<em :class="org.isActive ? 'on' : 'off'">{{ org.isActive ? 'فعال' : 'غیرفعال' }}</em>
|
||||
<small>
|
||||
{{ toFaDigits(org.memberCount) }} عضو ·
|
||||
{{ toFaDigits(org.personCount) }} شخص ·
|
||||
{{ formatJalaliNumeric(new Date(org.createdAt)) }}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" type="button" @click="startEdit(org)">ویرایش</button>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
:disabled="busyId === org.id"
|
||||
@click="setOrgActive(org, !org.isActive)"
|
||||
>
|
||||
{{ org.isActive ? 'غیرفعال کردن' : 'فعال کردن' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="editingId === org.id"
|
||||
class="edit-panel"
|
||||
@submit.prevent="saveEdit(org.id)"
|
||||
>
|
||||
<label>
|
||||
<span>نام</span>
|
||||
<input v-model="editForm.name" required minlength="2" />
|
||||
</label>
|
||||
<label>
|
||||
<span>اسلاگ</span>
|
||||
<input v-model="editForm.slug" required minlength="2" dir="ltr" />
|
||||
</label>
|
||||
<label>
|
||||
<span>زمینه فعالیت</span>
|
||||
<select v-model="editForm.businessField">
|
||||
<option value="">—</option>
|
||||
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
|
||||
{{ BUSINESS_FIELD_LABELS[field] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="panel-actions">
|
||||
<button class="btn" type="submit" :disabled="savingEdit">ذخیره</button>
|
||||
<button class="btn btn-ghost" type="button" @click="cancelEdit">انصراف</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="openId === org.id" class="detail">
|
||||
<p v-if="!detail?.members?.length" class="muted">عضوی ثبت نشده.</p>
|
||||
<ul v-else>
|
||||
<li v-for="member in detail.members" :key="member.membershipId">
|
||||
<div>
|
||||
<strong>{{ member.fullName }}</strong>
|
||||
<span>
|
||||
{{ member.email }}
|
||||
· حساب {{ member.userIsActive ? 'فعال' : 'غیرفعال' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="member-actions">
|
||||
<select
|
||||
:value="member.role"
|
||||
:disabled="busyId === `role-${member.membershipId}`"
|
||||
@change="
|
||||
setMembershipRole(
|
||||
member.membershipId,
|
||||
($event.target as HTMLSelectElement).value as UserRole,
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="role in USER_ROLES" :key="role" :value="role">
|
||||
{{ USER_ROLE_LABELS[role] }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
type="button"
|
||||
:disabled="busyId === member.membershipId"
|
||||
@click="setMembershipActive(member.membershipId, !member.isActive)"
|
||||
>
|
||||
{{ member.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
}
|
||||
|
||||
.head p,
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search input,
|
||||
.panel input,
|
||||
.panel select,
|
||||
.edit-panel input,
|
||||
.edit-panel select,
|
||||
.member-actions select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.65rem 0.8rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.search input {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.panel,
|
||||
.edit-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;
|
||||
}
|
||||
|
||||
.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;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.row-main span,
|
||||
.row-main small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.edit-panel {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.edit-panel .panel-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.detail {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.detail ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.detail li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.grid,
|
||||
.edit-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,420 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
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';
|
||||
|
||||
const query = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
const items = ref<PlatformUserDto[]>([]);
|
||||
const openId = ref<string | null>(null);
|
||||
const busyId = ref('');
|
||||
const resetUserId = ref<string | null>(null);
|
||||
const resetPassword = ref('');
|
||||
const resetting = ref(false);
|
||||
|
||||
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<PlatformUserDto[]>('/platform/users', {
|
||||
params: query.value.trim() ? { q: query.value.trim() } : undefined,
|
||||
});
|
||||
items.value = data;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری کاربران ناموفق بود');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOpen(id: string) {
|
||||
openId.value = openId.value === id ? null : id;
|
||||
if (resetUserId.value !== id) {
|
||||
resetUserId.value = null;
|
||||
resetPassword.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function startReset(userId: string) {
|
||||
resetUserId.value = resetUserId.value === userId ? null : userId;
|
||||
resetPassword.value = '';
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
}
|
||||
|
||||
async function submitReset(userId: string) {
|
||||
resetting.value = true;
|
||||
error.value = '';
|
||||
success.value = '';
|
||||
try {
|
||||
await platformApi.patch(`/platform/users/${userId}/password`, {
|
||||
password: resetPassword.value,
|
||||
});
|
||||
success.value = 'رمز عبور بهروز شد';
|
||||
resetUserId.value = null;
|
||||
resetPassword.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ریست رمز ناموفق بود');
|
||||
} finally {
|
||||
resetting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setUserActive(user: PlatformUserDto, isActive: boolean) {
|
||||
busyId.value = user.id;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await platformApi.patch<PlatformUserDto>(
|
||||
`/platform/users/${user.id}`,
|
||||
{ isActive },
|
||||
);
|
||||
items.value = items.value.map((row) => (row.id === user.id ? data : row));
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر وضعیت کاربر ناموفق بود');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function setMembershipActive(userId: string, membershipId: string, isActive: boolean) {
|
||||
busyId.value = membershipId;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await platformApi.patch<PlatformUserDto>(
|
||||
`/platform/memberships/${membershipId}`,
|
||||
{ isActive },
|
||||
);
|
||||
items.value = items.value.map((row) => (row.id === userId ? data : row));
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر عضویت ناموفق بود');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function setMembershipRole(userId: string, membershipId: string, role: UserRole) {
|
||||
busyId.value = `role-${membershipId}`;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await platformApi.patch<PlatformUserDto>(
|
||||
`/platform/memberships/${membershipId}/role`,
|
||||
{ role },
|
||||
);
|
||||
items.value = items.value.map((row) => (row.id === userId ? data : row));
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر نقش ناموفق بود');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>کاربران</h1>
|
||||
<p>فعال/غیرفعال حساب، نقش عضویت و ریست رمز عبور.</p>
|
||||
</div>
|
||||
<form class="search" @submit.prevent="load">
|
||||
<input v-model="query" type="search" placeholder="نام، ایمیل یا موبایل" />
|
||||
<button class="btn" type="submit" :disabled="loading">جستجو</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="success" class="ok">{{ success }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-else-if="!items.length" class="muted">کاربری پیدا نشد.</p>
|
||||
|
||||
<div v-else class="list">
|
||||
<article v-for="user in items" :key="user.id" class="row">
|
||||
<button type="button" class="row-main" @click="toggleOpen(user.id)">
|
||||
<div>
|
||||
<strong>{{ user.fullName }}</strong>
|
||||
<span>{{ user.email }}{{ user.mobile ? ` · ${user.mobile}` : '' }}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<em :class="user.isActive ? 'on' : 'off'">{{ user.isActive ? 'فعال' : 'غیرفعال' }}</em>
|
||||
<small>
|
||||
{{ toFaDigits(user.memberships.length) }} محیط
|
||||
<template v-if="user.lastLoginAt">
|
||||
· آخرین ورود {{ formatJalaliNumeric(new Date(user.lastLoginAt)) }}
|
||||
</template>
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" type="button" @click="startReset(user.id)">
|
||||
ریست رمز
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
:disabled="busyId === user.id"
|
||||
@click="setUserActive(user, !user.isActive)"
|
||||
>
|
||||
{{ user.isActive ? 'غیرفعال کردن حساب' : 'فعال کردن حساب' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-if="resetUserId === user.id"
|
||||
class="reset-panel"
|
||||
@submit.prevent="submitReset(user.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">
|
||||
{{ resetting ? 'در حال ذخیره…' : 'ذخیره رمز' }}
|
||||
</button>
|
||||
<button class="btn btn-ghost" type="button" @click="startReset(user.id)">
|
||||
انصراف
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="openId === user.id" class="detail">
|
||||
<p v-if="!user.memberships.length" class="muted">عضو هیچ محیطی نیست.</p>
|
||||
<ul v-else>
|
||||
<li v-for="mem in user.memberships" :key="mem.membershipId">
|
||||
<div>
|
||||
<strong>{{ mem.organizationName }}</strong>
|
||||
<span>
|
||||
محیط {{ mem.organizationIsActive ? 'فعال' : 'غیرفعال' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="member-actions">
|
||||
<select
|
||||
:value="mem.role"
|
||||
:disabled="busyId === `role-${mem.membershipId}`"
|
||||
@change="
|
||||
setMembershipRole(
|
||||
user.id,
|
||||
mem.membershipId,
|
||||
($event.target as HTMLSelectElement).value as UserRole,
|
||||
)
|
||||
"
|
||||
>
|
||||
<option v-for="role in USER_ROLES" :key="role" :value="role">
|
||||
{{ USER_ROLE_LABELS[role] }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
type="button"
|
||||
:disabled="busyId === mem.membershipId"
|
||||
@click="setMembershipActive(user.id, mem.membershipId, !mem.isActive)"
|
||||
>
|
||||
{{ mem.isActive ? 'قطع دسترسی' : 'بازگشت دسترسی' }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0 0 0.3rem;
|
||||
}
|
||||
|
||||
.head p,
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search input,
|
||||
.reset-panel input,
|
||||
.member-actions select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.65rem 0.8rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.search input {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.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;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.row-main span,
|
||||
.row-main small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.reset-panel label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.reset-panel span {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.detail {
|
||||
grid-column: 1 / -1;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.detail ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.detail li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.detail span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.member-actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -39,3 +39,7 @@ STT_INTERNAL_TOKEN=change-me-stt-internal-token
|
||||
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_PASSWORD=change-me-strong-platform-password
|
||||
PLATFORM_ADMIN_NAME=سوپرادمین
|
||||
|
||||
@@ -44,3 +44,9 @@ STT_TIMEOUT_MS=60000
|
||||
|
||||
# رمزنگاری توکنهای اتصال حسابداری (تهحساب) — ۳۲ بایت hex (۶۴ کاراکتر)
|
||||
INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32
|
||||
|
||||
# سوپرادمین پلتفرم — crm.montzar.ir/adm
|
||||
# بعد از تنظیم، از apps/api: npx prisma db seed
|
||||
PLATFORM_ADMIN_EMAIL=admin@montzar.ir
|
||||
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
|
||||
PLATFORM_ADMIN_NAME=سوپرادمین
|
||||
|
||||
+29
-1
@@ -87,6 +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`) |
|
||||
|
||||
نمونهٔ اتصال:
|
||||
|
||||
@@ -185,7 +186,34 @@ sudo nginx -t && sudo systemctl reload nginx
|
||||
1. `https://crm.montzar.ir` → ورود پنل
|
||||
2. دمو: `owner@demo.local` / `demo1234` (اگر seed زده باشید)
|
||||
3. پرتال: `https://crm.montzar.ir/p/demo-mento` → OTP باید پیامک شود
|
||||
4. منوها: گزارشات، کاریز پروژه، باشگاه مشتریان، اتوماسیون (مالک/مدیر)
|
||||
4. منوها: گزارشات، کاریز پروژه، باشگاه مشتریان، اتوماسیون (مالک/مدیر)
|
||||
5. سوپرادمین: `https://crm.montzar.ir/adm` (بعد از seed با `PLATFORM_ADMIN_*`)
|
||||
|
||||
---
|
||||
|
||||
## پنل سوپرادمین (`/adm`)
|
||||
|
||||
مسیر جدا روی nginx لازم نیست؛ همان SPA (`crm.montzar.ir`) روت `/adm` را سرو میکند.
|
||||
|
||||
1. در `apps/api/.env` این کلیدها را بگذارید (نمونه در `deploy/.env.production.example`):
|
||||
|
||||
```text
|
||||
PLATFORM_ADMIN_EMAIL=admin@montzar.ir
|
||||
PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password
|
||||
PLATFORM_ADMIN_NAME=سوپرادمین
|
||||
```
|
||||
|
||||
2. بعد از `prisma migrate deploy` یکبار seed بزنید تا حساب ساخته شود:
|
||||
|
||||
```bash
|
||||
cd /var/www/crm/apps/api
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
اگر حساب از قبل وجود داشته باشد، seed آن را عوض نمیکند. برای رمز جدید از همان پنل یا بهروزرسانی دستی دیتابیس استفاده کنید.
|
||||
|
||||
3. ورود: `https://crm.montzar.ir/adm` → داشبورد `/adm/dashboard`
|
||||
توکن سوپرادمین جدا از JWT کاربران سازمانی است؛ با حساب CRM معمولی نمیتوان وارد `/adm` شد.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2515,6 +2515,102 @@ export interface CreateMessageAutomationRuleRequest {
|
||||
export type UpdateMessageAutomationRuleRequest =
|
||||
Partial<CreateMessageAutomationRuleRequest>;
|
||||
|
||||
export interface PlatformAdminDto {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
}
|
||||
|
||||
export interface PlatformLoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PlatformLoginResponse {
|
||||
accessToken: string;
|
||||
admin: PlatformAdminDto;
|
||||
}
|
||||
|
||||
export interface PlatformStatsDto {
|
||||
organizationCount: number;
|
||||
activeOrganizationCount: number;
|
||||
userCount: number;
|
||||
activeUserCount: number;
|
||||
membershipCount: number;
|
||||
}
|
||||
|
||||
export interface PlatformOrgMemberDto {
|
||||
membershipId: string;
|
||||
userId: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
userIsActive: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformOrganizationDto {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
businessField?: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
memberCount: number;
|
||||
personCount: number;
|
||||
dealCount: number;
|
||||
members?: PlatformOrgMemberDto[];
|
||||
}
|
||||
|
||||
export interface PlatformUserMembershipDto {
|
||||
membershipId: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
organizationIsActive: boolean;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformUserDto {
|
||||
id: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
fullName: string;
|
||||
isActive: boolean;
|
||||
lastLoginAt?: string | null;
|
||||
createdAt: string;
|
||||
memberships: PlatformUserMembershipDto[];
|
||||
}
|
||||
|
||||
export interface UpdatePlatformStatusRequest {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CreatePlatformOrganizationRequest {
|
||||
name: string;
|
||||
businessField?: string;
|
||||
ownerFullName: string;
|
||||
ownerEmail: string;
|
||||
ownerPassword: string;
|
||||
ownerMobile?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePlatformOrganizationRequest {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
businessField?: string | null;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePlatformMembershipRoleRequest {
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface ResetPlatformUserPasswordRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
export { brand, APP_NAME, APP_NAME_FA, brandSuggestionFa } from "./brand";
|
||||
export type { Brand } from "./brand";
|
||||
export const API_PREFIX = "api";
|
||||
|
||||
Reference in New Issue
Block a user