diff --git a/.env.example b/.env.example index ff83b09..d590103 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/prisma/migrations/20260919180000_platform_admin/migration.sql b/apps/api/prisma/migrations/20260919180000_platform_admin/migration.sql new file mode 100644 index 0000000..a35e4a9 --- /dev/null +++ b/apps/api/prisma/migrations/20260919180000_platform_admin/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0b1827d..a9a1feb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index c95bad0..f7c2e4b 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -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) { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d69ccaf..58658a7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/auth/jwt.strategy.ts b/apps/api/src/auth/jwt.strategy.ts index 3ca78ff..182e909 100644 --- a/apps/api/src/auth/jwt.strategy.ts +++ b/apps/api/src/auth/jwt.strategy.ts @@ -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 }, }); diff --git a/apps/api/src/memberships/memberships.service.ts b/apps/api/src/memberships/memberships.service.ts index 9ef165c..5767e16 100644 --- a/apps/api/src/memberships/memberships.service.ts +++ b/apps/api/src/memberships/memberships.service.ts @@ -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 { 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( diff --git a/apps/api/src/platform/dto.ts b/apps/api/src/platform/dto.ts new file mode 100644 index 0000000..2402515 --- /dev/null +++ b/apps/api/src/platform/dto.ts @@ -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; +} diff --git a/apps/api/src/platform/platform-auth.controller.ts b/apps/api/src/platform/platform-auth.controller.ts new file mode 100644 index 0000000..d1818cd --- /dev/null +++ b/apps/api/src/platform/platform-auth.controller.ts @@ -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); + } +} diff --git a/apps/api/src/platform/platform-auth.guard.ts b/apps/api/src/platform/platform-auth.guard.ts new file mode 100644 index 0000000..65ddcde --- /dev/null +++ b/apps/api/src/platform/platform-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class PlatformAuthGuard extends AuthGuard('platform-jwt') {} diff --git a/apps/api/src/platform/platform-auth.service.ts b/apps/api/src/platform/platform-auth.service.ts new file mode 100644 index 0000000..28eaf36 --- /dev/null +++ b/apps/api/src/platform/platform-auth.service.ts @@ -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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/src/platform/platform-jwt.strategy.ts b/apps/api/src/platform/platform-jwt.strategy.ts new file mode 100644 index 0000000..a8950e0 --- /dev/null +++ b/apps/api/src/platform/platform-jwt.strategy.ts @@ -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('JWT_SECRET') ?? 'dev-secret', + }); + } + + async validate(payload: PlatformJwtPayload): Promise { + 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, + }; + } +} diff --git a/apps/api/src/platform/platform.controller.ts b/apps/api/src/platform/platform.controller.ts new file mode 100644 index 0000000..7196491 --- /dev/null +++ b/apps/api/src/platform/platform.controller.ts @@ -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); + } +} diff --git a/apps/api/src/platform/platform.module.ts b/apps/api/src/platform/platform.module.ts new file mode 100644 index 0000000..67943b8 --- /dev/null +++ b/apps/api/src/platform/platform.module.ts @@ -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 {} diff --git a/apps/api/src/platform/platform.service.ts b/apps/api/src/platform/platform.service.ts new file mode 100644 index 0000000..0472c9e --- /dev/null +++ b/apps/api/src/platform/platform.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.updateOrganization(id, { isActive }); + } + + async listUsers(query?: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + })), + }; + } +} diff --git a/apps/web/src/layouts/AdmLayout.vue b/apps/web/src/layouts/AdmLayout.vue new file mode 100644 index 0000000..11fb86c --- /dev/null +++ b/apps/web/src/layouts/AdmLayout.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0c2a488..14a93fc 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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'); diff --git a/apps/web/src/lib/platform-api.ts b/apps/web/src/lib/platform-api.ts new file mode 100644 index 0000000..6b62d85 --- /dev/null +++ b/apps/web/src/lib/platform-api.ts @@ -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); + }, +); diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 97074ef..584c598 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -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 } }; diff --git a/apps/web/src/stores/platform-auth.ts b/apps/web/src/stores/platform-auth.ts new file mode 100644 index 0000000..0560908 --- /dev/null +++ b/apps/web/src/stores/platform-auth.ts @@ -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(localStorage.getItem(TOKEN_KEY)); + const admin = ref(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( + '/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('/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, + }; +}); diff --git a/apps/web/src/views/adm/AdmDashboardView.vue b/apps/web/src/views/adm/AdmDashboardView.vue new file mode 100644 index 0000000..50b79ff --- /dev/null +++ b/apps/web/src/views/adm/AdmDashboardView.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/apps/web/src/views/adm/AdmLoginView.vue b/apps/web/src/views/adm/AdmLoginView.vue new file mode 100644 index 0000000..85bd7ca --- /dev/null +++ b/apps/web/src/views/adm/AdmLoginView.vue @@ -0,0 +1,324 @@ + + + + + diff --git a/apps/web/src/views/adm/AdmOrganizationsView.vue b/apps/web/src/views/adm/AdmOrganizationsView.vue new file mode 100644 index 0000000..6ced561 --- /dev/null +++ b/apps/web/src/views/adm/AdmOrganizationsView.vue @@ -0,0 +1,611 @@ + + + + + diff --git a/apps/web/src/views/adm/AdmUsersView.vue b/apps/web/src/views/adm/AdmUsersView.vue new file mode 100644 index 0000000..7f4034e --- /dev/null +++ b/apps/web/src/views/adm/AdmUsersView.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/deploy/.env.crm2.example b/deploy/.env.crm2.example index fdf2405..f22f439 100644 --- a/deploy/.env.crm2.example +++ b/deploy/.env.crm2.example @@ -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=سوپرادمین diff --git a/deploy/.env.production.example b/deploy/.env.production.example index 2ad03aa..a66c728 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -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=سوپرادمین diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index b126119..1396f8b 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -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` شد. --- diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 99fce49..e09c5ae 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2515,6 +2515,102 @@ export interface CreateMessageAutomationRuleRequest { export type UpdateMessageAutomationRuleRequest = Partial; +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";