diff --git a/.env.example b/.env.example index d590103..2cecd0a 100644 --- a/.env.example +++ b/.env.example @@ -24,10 +24,12 @@ SMS_SENDER= # مثال ساخت: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" INTEGRATIONS_ENCRYPTION_KEY= -# سوپرادمین پلتفرم — ورود از /adm -# در لوکال اگر خالی بماند: platform@demo.local / demo1234 -PLATFORM_ADMIN_EMAIL= +# سوپرادمین پلتفرم — ورود از /adm با موبایل + رمز +# در لوکال اگر خالی بماند: 09120000000 / demo1234 +PLATFORM_ADMIN_MOBILE= PLATFORM_ADMIN_PASSWORD= PLATFORM_ADMIN_NAME=سوپرادمین +# اختیاری +# PLATFORM_ADMIN_EMAIL= # Production: see deploy/.env.production.example and deploy/DEPLOY.md diff --git a/apps/api/prisma/migrations/20260919220000_platform_admin_mobile/migration.sql b/apps/api/prisma/migrations/20260919220000_platform_admin_mobile/migration.sql new file mode 100644 index 0000000..89c1744 --- /dev/null +++ b/apps/api/prisma/migrations/20260919220000_platform_admin_mobile/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "PlatformAdmin" ADD COLUMN IF NOT EXISTS "mobile" TEXT; + +-- Make email optional (existing rows keep their email) +ALTER TABLE "PlatformAdmin" ALTER COLUMN "email" DROP NOT NULL; + +-- Unique mobile +CREATE UNIQUE INDEX IF NOT EXISTS "PlatformAdmin_mobile_key" ON "PlatformAdmin"("mobile"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a9a1feb..2c0302f 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -282,7 +282,9 @@ model Membership { /// حساب سوپرادمین پلتفرم — جدا از User سازمانی model PlatformAdmin { id String @id @default(cuid()) - email String @unique + /// ورود اصلی با موبایل نرمال‌شده (مثلاً 0912…) + mobile String? @unique + email String? @unique passwordHash String fullName String isActive Boolean @default(true) diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index f7c2e4b..cf0e4ba 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -14,16 +14,18 @@ const DEFAULT_STAGES = [ ] as const; async function seedPlatformAdmin() { + const envMobile = (process.env.PLATFORM_ADMIN_MOBILE ?? '').trim(); const envEmail = (process.env.PLATFORM_ADMIN_EMAIL ?? '').trim().toLowerCase(); const envPassword = process.env.PLATFORM_ADMIN_PASSWORD ?? ''; const envName = (process.env.PLATFORM_ADMIN_NAME ?? 'سوپرادمین').trim() || 'سوپرادمین'; const isProd = process.env.NODE_ENV === 'production'; - const email = envEmail || (isProd ? '' : 'platform@demo.local'); + const mobileRaw = envMobile || (isProd ? '' : '09120000000'); const password = envPassword || (isProd ? '' : 'demo1234'); + const email = envEmail || null; - if (!email || !password) { + if (!mobileRaw || !password) { console.log( - 'Platform admin seed skipped (set PLATFORM_ADMIN_EMAIL and PLATFORM_ADMIN_PASSWORD)', + 'Platform admin seed skipped (set PLATFORM_ADMIN_MOBILE and PLATFORM_ADMIN_PASSWORD)', ); return; } @@ -32,17 +34,43 @@ async function seedPlatformAdmin() { return; } - const existing = await prisma.platformAdmin.findUnique({ where: { email } }); - if (existing) { - console.log('Platform admin exists:', email); + // normalize: 09xxxxxxxxx + let mobile = mobileRaw.replace(/[\s\-()]/g, ''); + if (mobile.startsWith('+98')) mobile = `0${mobile.slice(3)}`; + if (mobile.startsWith('98') && mobile.length === 12) mobile = `0${mobile.slice(2)}`; + if (/^9\d{9}$/.test(mobile)) mobile = `0${mobile}`; + if (!/^09\d{9}$/.test(mobile)) { + console.log('Platform admin seed skipped (PLATFORM_ADMIN_MOBILE invalid)'); return; } + const existingByMobile = await prisma.platformAdmin.findUnique({ where: { mobile } }); + if (existingByMobile) { + console.log('Platform admin exists:', mobile); + return; + } + if (email) { + const existingByEmail = await prisma.platformAdmin.findUnique({ where: { email } }); + if (existingByEmail) { + await prisma.platformAdmin.update({ + where: { id: existingByEmail.id }, + data: { mobile, ...(envName ? { fullName: envName } : {}) }, + }); + console.log('Platform admin linked mobile:', mobile, '→', email); + return; + } + } + const passwordHash = await bcrypt.hash(password, 10); await prisma.platformAdmin.create({ - data: { email, passwordHash, fullName: envName }, + data: { + mobile, + email, + passwordHash, + fullName: envName, + }, }); - console.log('Seeded platform admin:', email); + console.log('Seeded platform admin:', mobile); } async function main() { diff --git a/apps/api/src/platform/dto.ts b/apps/api/src/platform/dto.ts index 2402515..6e50c9a 100644 --- a/apps/api/src/platform/dto.ts +++ b/apps/api/src/platform/dto.ts @@ -11,14 +11,44 @@ import { import { USER_ROLES, type UserRole } from '@mento/shared'; export class PlatformLoginDto { - @IsEmail() - email!: string; + @IsString() + @MinLength(8) + @MaxLength(40) + mobile!: string; @IsString() @MinLength(6) password!: string; } +export class CreatePlatformAdminDto { + @IsString() + @MinLength(2) + @MaxLength(120) + fullName!: string; + + @IsString() + @MinLength(8) + @MaxLength(40) + mobile!: string; + + @IsString() + @MinLength(8) + @MaxLength(120) + password!: string; + + @IsOptional() + @IsEmail() + email?: string; +} + +export class ResetPlatformAdminPasswordDto { + @IsString() + @MinLength(8) + @MaxLength(120) + password!: string; +} + export class UpdatePlatformStatusDto { @IsBoolean() isActive!: boolean; diff --git a/apps/api/src/platform/platform-auth.service.ts b/apps/api/src/platform/platform-auth.service.ts index 28eaf36..b23eea3 100644 --- a/apps/api/src/platform/platform-auth.service.ts +++ b/apps/api/src/platform/platform-auth.service.ts @@ -1,7 +1,13 @@ -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcryptjs'; import type { PlatformAdminDto, PlatformLoginResponse } from '@mento/shared'; +import { normalizeMobile } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { PlatformLoginDto } from './dto'; @@ -13,9 +19,12 @@ export class PlatformAuthService { ) {} async login(dto: PlatformLoginDto): Promise { - const email = dto.email.trim().toLowerCase(); + const mobile = normalizeMobile(dto.mobile); + if (!mobile) { + throw new UnauthorizedException('اطلاعات ورود نادرست است'); + } const admin = await this.prisma.platformAdmin.findUnique({ - where: { email }, + where: { mobile }, }); if (!admin || admin.isActive === false) { throw new UnauthorizedException('اطلاعات ورود نادرست است'); @@ -47,15 +56,25 @@ export class PlatformAuthService { return this.toDto(admin); } - private toDto(admin: { + toDto(admin: { id: string; - email: string; + mobile: string | null; + email: string | null; fullName: string; + isActive?: boolean; + lastLoginAt?: Date | null; + createdAt?: Date; }): PlatformAdminDto { return { id: admin.id, + mobile: admin.mobile, email: admin.email, fullName: admin.fullName, + ...(admin.isActive !== undefined ? { isActive: admin.isActive } : {}), + ...(admin.lastLoginAt !== undefined + ? { lastLoginAt: admin.lastLoginAt?.toISOString() ?? null } + : {}), + ...(admin.createdAt ? { createdAt: admin.createdAt.toISOString() } : {}), }; } } diff --git a/apps/api/src/platform/platform-jwt.strategy.ts b/apps/api/src/platform/platform-jwt.strategy.ts index a8950e0..3cf0ec5 100644 --- a/apps/api/src/platform/platform-jwt.strategy.ts +++ b/apps/api/src/platform/platform-jwt.strategy.ts @@ -11,7 +11,8 @@ export type PlatformJwtPayload = { export type PlatformActor = { platformAdminId: string; - email: string; + mobile: string | null; + email: string | null; fullName: string; }; @@ -39,6 +40,7 @@ export class PlatformJwtStrategy extends PassportStrategy( where: { id: payload.sub }, select: { id: true, + mobile: true, email: true, fullName: true, isActive: true, @@ -49,6 +51,7 @@ export class PlatformJwtStrategy extends PassportStrategy( } return { platformAdminId: admin.id, + mobile: admin.mobile, email: admin.email, fullName: admin.fullName, }; diff --git a/apps/api/src/platform/platform.controller.ts b/apps/api/src/platform/platform.controller.ts index 7196491..f09daaa 100644 --- a/apps/api/src/platform/platform.controller.ts +++ b/apps/api/src/platform/platform.controller.ts @@ -8,14 +8,18 @@ import { Query, UseGuards, } from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; import { + CreatePlatformAdminDto, CreatePlatformOrganizationDto, + ResetPlatformAdminPasswordDto, ResetPlatformUserPasswordDto, UpdatePlatformMembershipRoleDto, UpdatePlatformOrganizationDto, UpdatePlatformStatusDto, } from './dto'; import { PlatformAuthGuard } from './platform-auth.guard'; +import type { PlatformActor } from './platform-jwt.strategy'; import { PlatformService } from './platform.service'; @Controller('platform') @@ -28,6 +32,33 @@ export class PlatformController { return this.platform.stats(); } + @Get('admins') + listAdmins() { + return this.platform.listAdmins(); + } + + @Post('admins') + createAdmin(@Body() dto: CreatePlatformAdminDto) { + return this.platform.createAdmin(dto); + } + + @Patch('admins/:id') + setAdminActive( + @Param('id') id: string, + @Body() dto: UpdatePlatformStatusDto, + @CurrentUser() actor: PlatformActor, + ) { + return this.platform.setAdminActive(id, dto.isActive, actor.platformAdminId); + } + + @Patch('admins/:id/password') + resetAdminPassword( + @Param('id') id: string, + @Body() dto: ResetPlatformAdminPasswordDto, + ) { + return this.platform.resetAdminPassword(id, dto); + } + @Get('organizations') listOrganizations(@Query('q') q?: string) { return this.platform.listOrganizations(q); diff --git a/apps/api/src/platform/platform.service.ts b/apps/api/src/platform/platform.service.ts index 0472c9e..0fe4b32 100644 --- a/apps/api/src/platform/platform.service.ts +++ b/apps/api/src/platform/platform.service.ts @@ -5,6 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import type { + PlatformAdminDto, PlatformOrganizationDto, PlatformStatsDto, PlatformUserDto, @@ -19,7 +20,9 @@ import { OrgRolesService } from '../org-roles/org-roles.service'; import { PipelinesService } from '../pipelines/pipelines.service'; import { PrismaService } from '../prisma/prisma.service'; import type { + CreatePlatformAdminDto, CreatePlatformOrganizationDto, + ResetPlatformAdminPasswordDto, ResetPlatformUserPasswordDto, UpdatePlatformOrganizationDto, } from './dto'; @@ -472,6 +475,133 @@ export class PlatformService { return this.getUser(row.userId); } + async listAdmins(): Promise { + const rows = await this.prisma.platformAdmin.findMany({ + orderBy: { createdAt: 'asc' }, + }); + return rows.map((row) => this.adminToDto(row)); + } + + async createAdmin(dto: CreatePlatformAdminDto): Promise { + const fullName = dto.fullName.trim(); + if (fullName.length < 2) { + throw new BadRequestException('نام معتبر نیست'); + } + const mobile = normalizeMobile(dto.mobile); + if (!mobile) { + throw new BadRequestException('شماره موبایل معتبر نیست'); + } + const password = dto.password.trim(); + if (password.length < 8) { + throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است'); + } + const email = dto.email?.trim().toLowerCase() || null; + + const existingMobile = await this.prisma.platformAdmin.findUnique({ + where: { mobile }, + select: { id: true }, + }); + if (existingMobile) { + throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); + } + if (email) { + const existingEmail = await this.prisma.platformAdmin.findUnique({ + where: { email }, + select: { id: true }, + }); + if (existingEmail) { + throw new ConflictException('این ایمیل قبلاً ثبت شده است'); + } + } + + const passwordHash = await bcrypt.hash(password, 10); + try { + const created = await this.prisma.platformAdmin.create({ + data: { + fullName, + mobile, + email, + passwordHash, + }, + }); + return this.adminToDto(created); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new ConflictException('موبایل یا ایمیل تکراری است'); + } + throw e; + } + } + + async setAdminActive( + id: string, + isActive: boolean, + actorId: string, + ): Promise { + const existing = await this.prisma.platformAdmin.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!existing) throw new NotFoundException('ادمین پیدا نشد'); + if (!isActive && id === actorId) { + throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید'); + } + if (!isActive) { + const activeCount = await this.prisma.platformAdmin.count({ + where: { isActive: true, id: { not: id } }, + }); + if (activeCount < 1) { + throw new BadRequestException('حداقل یک سوپرادمین فعال لازم است'); + } + } + const updated = await this.prisma.platformAdmin.update({ + where: { id }, + data: { isActive }, + }); + return this.adminToDto(updated); + } + + async resetAdminPassword( + id: string, + dto: ResetPlatformAdminPasswordDto, + ): Promise<{ ok: true }> { + const existing = await this.prisma.platformAdmin.findUnique({ + where: { id }, + select: { id: true }, + }); + if (!existing) throw new NotFoundException('ادمین پیدا نشد'); + const password = dto.password.trim(); + if (password.length < 8) { + throw new BadRequestException('رمز عبور حداقل ۸ کاراکتر لازم است'); + } + const passwordHash = await bcrypt.hash(password, 10); + await this.prisma.platformAdmin.update({ + where: { id }, + data: { passwordHash }, + }); + return { ok: true }; + } + + private adminToDto(row: { + id: string; + mobile: string | null; + email: string | null; + fullName: string; + isActive: boolean; + lastLoginAt: Date | null; + createdAt: Date; + }): PlatformAdminDto { + return { + id: row.id, + mobile: row.mobile, + email: row.email, + fullName: row.fullName, + isActive: row.isActive, + lastLoginAt: row.lastLoginAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; + } + private orgToDto(row: { id: string; name: string; diff --git a/apps/web/src/layouts/AdmLayout.vue b/apps/web/src/layouts/AdmLayout.vue index 11fb86c..a4fb64a 100644 --- a/apps/web/src/layouts/AdmLayout.vue +++ b/apps/web/src/layouts/AdmLayout.vue @@ -41,6 +41,10 @@ function onLogout() { کاربران + + + ادمین‌ها +
@@ -160,7 +164,7 @@ function onLogout() { } .nav { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); flex: none; } diff --git a/apps/web/src/router/index.ts b/apps/web/src/router/index.ts index 584c598..ad80861 100644 --- a/apps/web/src/router/index.ts +++ b/apps/web/src/router/index.ts @@ -56,6 +56,11 @@ export const router = createRouter({ name: 'adm-users', component: () => import('@/views/adm/AdmUsersView.vue'), }, + { + path: 'admins', + name: 'adm-admins', + component: () => import('@/views/adm/AdmAdminsView.vue'), + }, ], }, { diff --git a/apps/web/src/stores/platform-auth.ts b/apps/web/src/stores/platform-auth.ts index 0560908..d5d8003 100644 --- a/apps/web/src/stores/platform-auth.ts +++ b/apps/web/src/stores/platform-auth.ts @@ -28,10 +28,10 @@ export const usePlatformAuthStore = defineStore('platform-auth', () => { localStorage.setItem(ADMIN_KEY, JSON.stringify(payload.admin)); } - async function login(email: string, password: string) { + async function login(mobile: string, password: string) { const { data } = await platformApi.post( '/platform/auth/login', - { email, password }, + { mobile, password }, ); persist(data); } diff --git a/apps/web/src/views/adm/AdmAdminsView.vue b/apps/web/src/views/adm/AdmAdminsView.vue new file mode 100644 index 0000000..617a28a --- /dev/null +++ b/apps/web/src/views/adm/AdmAdminsView.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/apps/web/src/views/adm/AdmDashboardView.vue b/apps/web/src/views/adm/AdmDashboardView.vue index 50b79ff..ae7452b 100644 --- a/apps/web/src/views/adm/AdmDashboardView.vue +++ b/apps/web/src/views/adm/AdmDashboardView.vue @@ -82,6 +82,13 @@ onMounted(() => { نقش، دسترسی و ریست رمز عبور
+ + +
+ سوپرادمین‌ها + افزودن ادمین با موبایل و رمز +
+
@@ -156,7 +163,7 @@ onMounted(() => { .shortcuts { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.85rem; } diff --git a/apps/web/src/views/adm/AdmLoginView.vue b/apps/web/src/views/adm/AdmLoginView.vue index 85bd7ca..8ef118a 100644 --- a/apps/web/src/views/adm/AdmLoginView.vue +++ b/apps/web/src/views/adm/AdmLoginView.vue @@ -12,7 +12,7 @@ import { brand } from '@mento/shared'; const platform = usePlatformAuthStore(); const router = useRouter(); -const email = ref(''); +const mobile = ref(''); const password = ref(''); const showPassword = ref(false); const error = ref(''); @@ -33,7 +33,7 @@ async function submit() { error.value = ''; loading.value = true; try { - await platform.login(email.value.trim(), password.value); + await platform.login(mobile.value.trim(), password.value); await router.push({ name: 'adm-dashboard' }); } catch (e) { error.value = apiError(e, 'ورود ناموفق بود'); @@ -64,19 +64,20 @@ async function submit() {
- +
diff --git a/deploy/.env.crm2.example b/deploy/.env.crm2.example index f22f439..385c86e 100644 --- a/deploy/.env.crm2.example +++ b/deploy/.env.crm2.example @@ -40,6 +40,8 @@ STT_TIMEOUT_MS=60000 INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32 -PLATFORM_ADMIN_EMAIL=admin@montzar.ir +# سوپرادمین پلتفرم — ورود با موبایل + رمز +PLATFORM_ADMIN_MOBILE=09121234567 PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password PLATFORM_ADMIN_NAME=سوپرادمین +# PLATFORM_ADMIN_EMAIL= diff --git a/deploy/.env.production.example b/deploy/.env.production.example index a66c728..28e35ec 100644 --- a/deploy/.env.production.example +++ b/deploy/.env.production.example @@ -45,8 +45,9 @@ STT_TIMEOUT_MS=60000 # رمزنگاری توکن‌های اتصال حسابداری (ته‌حساب) — ۳۲ بایت hex (۶۴ کاراکتر) INTEGRATIONS_ENCRYPTION_KEY=change-me-64-hex-chars-use-openssl-rand-hex-32 -# سوپرادمین پلتفرم — crm.montzar.ir/adm +# سوپرادمین پلتفرم — crm.montzar.ir/adm (ورود با موبایل + رمز) # بعد از تنظیم، از apps/api: npx prisma db seed -PLATFORM_ADMIN_EMAIL=admin@montzar.ir +PLATFORM_ADMIN_MOBILE=09121234567 PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password PLATFORM_ADMIN_NAME=سوپرادمین +# PLATFORM_ADMIN_EMAIL= diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index 1396f8b..a43c973 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -87,7 +87,7 @@ nano apps/api/.env | `SMS_*` | همان ترِز مونتایم | | `PORTAL_SHOW_OTP` | در production حتماً `false` | | `INTEGRATIONS_ENCRYPTION_KEY` | ۶۴ کاراکتر hex برای رمز توکن ته‌حساب (`openssl rand -hex 32`) | -| `PLATFORM_ADMIN_EMAIL` / `PASSWORD` / `NAME` | حساب سوپرادمین برای `/adm` (با `npx prisma db seed`) | +| `PLATFORM_ADMIN_MOBILE` / `PASSWORD` / `NAME` | حساب اولیه سوپرادمین برای `/adm` (با `npx prisma db seed`) | نمونهٔ اتصال: @@ -193,27 +193,27 @@ sudo nginx -t && sudo systemctl reload nginx ## پنل سوپرادمین (`/adm`) -مسیر جدا روی nginx لازم نیست؛ همان SPA (`crm.montzar.ir`) روت `/adm` را سرو می‌کند. +مسیر جدا روی nginx لازم نیست؛ همان SPA (`crm.montzar.ir`) روت `/adm` را سرو می‌کند. ورود با **موبایل + رمز** است. 1. در `apps/api/.env` این کلیدها را بگذارید (نمونه در `deploy/.env.production.example`): ```text -PLATFORM_ADMIN_EMAIL=admin@montzar.ir +PLATFORM_ADMIN_MOBILE=09121234567 PLATFORM_ADMIN_PASSWORD=change-me-strong-platform-password PLATFORM_ADMIN_NAME=سوپرادمین ``` -2. بعد از `prisma migrate deploy` یک‌بار seed بزنید تا حساب ساخته شود: +2. بعد از `prisma migrate deploy` یک‌بار seed بزنید تا حساب اولیه ساخته شود: ```bash cd /var/www/crm/apps/api npx prisma db seed ``` -اگر حساب از قبل وجود داشته باشد، seed آن را عوض نمی‌کند. برای رمز جدید از همان پنل یا به‌روزرسانی دستی دیتابیس استفاده کنید. +اگر حساب با همان موبایل از قبل باشد، seed آن را عوض نمی‌کند. ادمین‌های بعدی را از داخل پنل (`/adm/admins`) اضافه کنید. 3. ورود: `https://crm.montzar.ir/adm` → داشبورد `/adm/dashboard` -توکن سوپرادمین جدا از JWT کاربران سازمانی است؛ با حساب CRM معمولی نمی‌توان وارد `/adm` شد. +توکن سوپرادمین جدا از JWT کاربران سازمانی است. --- diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e09c5ae..20f121b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2517,12 +2517,16 @@ export type UpdateMessageAutomationRuleRequest = export interface PlatformAdminDto { id: string; - email: string; + mobile?: string | null; + email?: string | null; fullName: string; + isActive?: boolean; + lastLoginAt?: string | null; + createdAt?: string; } export interface PlatformLoginRequest { - email: string; + mobile: string; password: string; } @@ -2531,6 +2535,17 @@ export interface PlatformLoginResponse { admin: PlatformAdminDto; } +export interface CreatePlatformAdminRequest { + fullName: string; + mobile: string; + password: string; + email?: string; +} + +export interface ResetPlatformAdminPasswordRequest { + password: string; +} + export interface PlatformStatsDto { organizationCount: number; activeOrganizationCount: number;