Multi-environment and multi-user construction

This commit is contained in:
2026-09-15 21:37:58 +03:30
parent e61fad95c6
commit d3f97b9dc4
37 changed files with 1349 additions and 201 deletions
@@ -0,0 +1,55 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "Membership" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'sales',
"orgRoleId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"commissionPercent" DECIMAL(8,2) NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "Membership_userId_organizationId_key" ON "Membership"("userId", "organizationId");
CREATE INDEX IF NOT EXISTS "Membership_organizationId_role_idx" ON "Membership"("organizationId", "role");
CREATE INDEX IF NOT EXISTS "Membership_userId_idx" ON "Membership"("userId");
CREATE INDEX IF NOT EXISTS "Membership_orgRoleId_idx" ON "Membership"("orgRoleId");
ALTER TABLE "Membership" DROP CONSTRAINT IF EXISTS "Membership_userId_fkey";
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Membership" DROP CONSTRAINT IF EXISTS "Membership_organizationId_fkey";
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Membership" DROP CONSTRAINT IF EXISTS "Membership_orgRoleId_fkey";
ALTER TABLE "Membership" ADD CONSTRAINT "Membership_orgRoleId_fkey" FOREIGN KEY ("orgRoleId") REFERENCES "OrgRole"("id") ON DELETE SET NULL ON UPDATE CASCADE;
INSERT INTO "Membership" (
"id",
"userId",
"organizationId",
"role",
"orgRoleId",
"isActive",
"commissionPercent",
"createdAt",
"updatedAt"
)
SELECT
md5(random()::text || clock_timestamp()::text || u."id")::text,
u."id",
u."organizationId",
u."role",
u."orgRoleId",
COALESCE(u."isActive", true),
COALESCE(u."commissionPercent", 0),
u."createdAt",
CURRENT_TIMESTAMP
FROM "User" u
WHERE NOT EXISTS (
SELECT 1 FROM "Membership" m
WHERE m."userId" = u."id" AND m."organizationId" = u."organizationId"
);
+23
View File
@@ -150,6 +150,7 @@ model Organization {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
memberships Membership[]
orgRoles OrgRole[]
companies Company[]
people Person[]
@@ -227,6 +228,7 @@ model User {
reportedTickets Ticket[] @relation("TicketReporter")
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
memberships Membership[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -248,6 +250,7 @@ model OrgRole {
/// owner | admin | sales | support — نقش‌های سیستمی قابل حذف نیستند
systemKey String?
users User[]
memberships Membership[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -255,6 +258,26 @@ model OrgRole {
@@index([organizationId])
}
model Membership {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
role UserRole @default(sales)
orgRoleId String?
orgRole OrgRole? @relation(fields: [orgRoleId], references: [id], onDelete: SetNull)
isActive Boolean @default(true)
commissionPercent Decimal @default(0) @db.Decimal(8, 2)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, organizationId])
@@index([organizationId, role])
@@index([userId])
@@index([orgRoleId])
}
model Company {
id String @id @default(cuid())
organizationId String
+21
View File
@@ -139,6 +139,27 @@ async function main() {
console.log('Seeded admin user:', adminEmail, '/ demo1234');
}
async function ensureMembership(
userId: string,
organizationId: string,
role: 'owner' | 'admin' | 'sales' | 'support',
) {
await prisma.membership.upsert({
where: { userId_organizationId: { userId, organizationId } },
create: { userId, organizationId, role },
update: { role },
});
}
const ownerRow = await prisma.user.findFirst({
where: { email, organizationId: org.id },
});
if (ownerRow) await ensureMembership(ownerRow.id, org.id, 'owner');
const salesRow = await prisma.user.findFirst({ where: { email: salesEmail } });
if (salesRow) await ensureMembership(salesRow.id, org.id, 'sales');
const adminRow = await prisma.user.findFirst({ where: { email: adminEmail } });
if (adminRow) await ensureMembership(adminRow.id, org.id, 'admin');
let peopleCount = await prisma.person.count({
where: { organizationId: org.id },
});
@@ -272,7 +272,10 @@ export class ActivitiesExcelService {
const n = name?.trim();
if (!n) return null;
const row = await this.prisma.user.findFirst({
where: { organizationId, fullName: { contains: n, mode: 'insensitive' } },
where: {
fullName: { contains: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
return row?.id ?? null;
@@ -420,7 +420,7 @@ export class ActivitiesService {
private async assertUserInOrg(organizationId: string, userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId },
where: { id: userId, memberships: { some: { organizationId } } },
select: { id: true },
});
if (!user) throw new BadRequestException('مسئول انتخاب‌شده معتبر نیست');
+25 -3
View File
@@ -19,6 +19,8 @@ import {
LoginOtpVerifyDto,
RegisterOrganizationDto,
RegisterVerifyOtpDto,
SelectOrganizationDto,
SwitchOrganizationDto,
UpdateProfileDto,
} from './dto';
import { JwtAuthGuard } from './jwt-auth.guard';
@@ -58,16 +60,36 @@ export class AuthController {
return this.authService.verifyLoginOtp(dto);
}
@Post('select-organization')
selectOrganization(@Body() dto: SelectOrganizationDto) {
return this.authService.selectOrganization(dto.selectionToken, dto.organizationId);
}
@UseGuards(JwtAuthGuard)
@Post('switch-organization')
switchOrganization(
@CurrentUser() user: AuthActor,
@Body() dto: SwitchOrganizationDto,
) {
return this.authService.switchOrganization(user.userId, dto.organizationId);
}
@UseGuards(JwtAuthGuard)
@Get('organizations')
listOrganizations(@CurrentUser() user: AuthActor) {
return this.authService.listMyOrganizations(user.userId);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: AuthActor) {
return this.authService.me(user.userId);
return this.authService.me(user.userId, user.organizationId);
}
@UseGuards(JwtAuthGuard)
@Patch('me')
updateMe(@CurrentUser() user: AuthActor, @Body() dto: UpdateProfileDto) {
return this.authService.updateMe(user.userId, dto);
return this.authService.updateMe(user.userId, user.organizationId, dto);
}
/** عکس پروفایل کاربر جاری */
@@ -96,6 +118,6 @@ export class AuthController {
@UseGuards(JwtAuthGuard)
@Delete('me/avatar')
removeAvatar(@CurrentUser() user: AuthActor) {
return this.authService.removeAvatar(user.userId);
return this.authService.removeAvatar(user.userId, user.organizationId);
}
}
+2
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MembershipsModule } from '../memberships/memberships.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
@@ -11,6 +12,7 @@ import { SmsService } from '../common/sms.service';
@Module({
imports: [
ConfigModule,
MembershipsModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
+127 -45
View File
@@ -10,11 +10,12 @@ import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import * as path from 'path';
import type { AuthUserDto, LoginResponse } from '@mento/shared';
import type { AuthUserDto, LoginResponse, LoginResult } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import { MembershipsService } from '../memberships/memberships.service';
import {
LoginDto,
LoginOtpRequestDto,
@@ -95,6 +96,7 @@ export class AuthService {
private readonly config: ConfigService,
private readonly sms: SmsService,
private readonly files: LocalFileStorage,
private readonly memberships: MembershipsService,
) {}
/**
@@ -249,6 +251,16 @@ export class AuthService {
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.prisma.authChallenge.update({
where: { id: challenge.id },
data: {
@@ -258,11 +270,11 @@ export class AuthService {
},
});
return this.buildLoginResponse(organization.users[0]);
return this.issueLogin(organization.users[0]!.id, organization.id);
}
/** ورود با رمز: ایمیل یا موبایل + رمز → JWT (بدون OTP) */
async loginWithPassword(dto: LoginDto): Promise<LoginResponse> {
async loginWithPassword(dto: LoginDto): Promise<LoginResult> {
const identifier = (dto.identifier ?? dto.email ?? '').trim();
const isEmail = looksLikeEmail(identifier);
const email = isEmail ? identifier.toLowerCase() : null;
@@ -287,7 +299,7 @@ export class AuthService {
throw new UnauthorizedException('اطلاعات ورود نادرست است');
}
return this.buildLoginResponse(user);
return this.finishLogin(user.id);
}
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -332,7 +344,7 @@ export class AuthService {
};
}
async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise<LoginResponse> {
async verifyLoginOtp(dto: LoginOtpVerifyDto): Promise<LoginResult> {
const challenge = await this.prisma.authChallenge.findUnique({
where: { id: dto.challengeId },
});
@@ -374,18 +386,19 @@ export class AuthService {
});
}
return this.buildLoginResponse(user);
return this.finishLogin(user.id);
}
async me(userId: string): Promise<AuthUserDto> {
async me(userId: string, organizationId: string): Promise<AuthUserDto> {
await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException();
}
return this.toAuthUserDto(user);
return this.toAuthUserDto(user, organizationId);
}
async updateMe(userId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
async updateMe(userId: string, organizationId: string, dto: UpdateProfileDto): Promise<AuthUserDto> {
const fullName = dto.fullName.trim();
if (fullName.length < 2) {
throw new BadRequestException('نام باید حداقل دو حرف باشد');
@@ -403,7 +416,7 @@ export class AuthService {
if (email !== existing.email) {
const taken = await this.prisma.user.findFirst({
where: { organizationId: existing.organizationId, email, id: { not: userId } },
where: { email, id: { not: userId } },
});
if (taken) {
throw new ConflictException('این ایمیل قبلاً ثبت شده است');
@@ -423,7 +436,7 @@ export class AuthService {
where: { id: userId },
data: { fullName, email, mobile },
});
return this.toAuthUserDto(user);
return this.toAuthUserDto(user, organizationId);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
@@ -464,10 +477,10 @@ export class AuthService {
await this.files.remove(prevKey);
}
await this.writeAvatarKey(userId, storageKey);
return this.toAuthUserDto(existing);
return this.toAuthUserDto(existing, organizationId);
}
async removeAvatar(userId: string): Promise<AuthUserDto> {
async removeAvatar(userId: string, organizationId: string): Promise<AuthUserDto> {
const existing = await this.prisma.user.findUnique({ where: { id: userId } });
if (!existing) {
throw new UnauthorizedException();
@@ -475,7 +488,7 @@ export class AuthService {
const prevKey = await this.readAvatarKey(userId);
if (prevKey) await this.files.remove(prevKey);
await this.writeAvatarKey(userId, null);
return this.toAuthUserDto(existing);
return this.toAuthUserDto(existing, organizationId);
}
async openAvatar(userId: string) {
@@ -510,16 +523,106 @@ export class AuthService {
`;
}
private async toAuthUserDto(user: {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
}): Promise<AuthUserDto> {
async listMyOrganizations(userId: string) {
const rows = await this.memberships.listActive(userId);
return this.memberships.toChoices(rows);
}
async selectOrganization(
selectionToken: string,
organizationId: string,
): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string };
try {
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken);
} catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
}
if (payload.typ !== 'org-select' || !payload.sub) {
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
}
return this.issueLogin(payload.sub, organizationId);
}
async switchOrganization(
userId: string,
organizationId: string,
): Promise<LoginResponse> {
return this.issueLogin(userId, organizationId);
}
private async finishLogin(userId: string): Promise<LoginResult> {
const rows = await this.memberships.listActive(userId);
if (!rows.length) {
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
}
if (rows.length === 1) {
return this.issueLogin(userId, rows[0]!.organization.id);
}
return {
requiresOrganizationChoice: true,
selectionToken: this.jwt.sign(
{ sub: userId, typ: 'org-select' },
{ expiresIn: '10m' },
),
organizations: this.memberships.toChoices(rows),
};
}
private async issueLogin(
userId: string,
organizationId: string,
): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.isActive === false) {
throw new UnauthorizedException('حساب کاربری غیرفعال است');
}
await this.memberships.syncUserLastOrg(
userId,
organizationId,
membership.role,
);
await this.prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() },
});
const accessToken = this.jwt.sign({
sub: userId,
organizationId,
role: membership.role,
});
return {
accessToken,
user: await this.toAuthUserDto(
{ ...user, role: membership.role, organizationId },
organizationId,
),
};
}
private async toAuthUserDto(
user: {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
},
organizationId: string,
): Promise<AuthUserDto> {
const membership = await this.prisma.membership.findFirst({
where: { userId: user.id, organizationId, isActive: true },
include: { organization: { select: { name: true } } },
});
const role = membership?.role ?? user.role;
const orgName = membership?.organization.name;
return this.toAuthUser({
...user,
role,
organizationId,
organizationName: orgName,
avatarStorageKey: await this.readAvatarKey(user.id),
});
}
@@ -596,29 +699,6 @@ export class AuthService {
this.logger.log(`[auth-otp-sms] ${mobile} => ${code}`);
}
private async buildLoginResponse(user: {
id: string;
email: string;
mobile?: string | null;
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
}): Promise<LoginResponse> {
const payload = {
sub: user.id,
organizationId: user.organizationId,
role: user.role,
};
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
return {
accessToken: this.jwt.sign(payload),
user: await this.toAuthUserDto(user),
};
}
private toAuthUser(user: {
id: string;
email: string;
@@ -626,6 +706,7 @@ export class AuthService {
fullName: string;
role: AuthUserDto['role'];
organizationId: string;
organizationName?: string;
avatarStorageKey?: string | null;
}): AuthUserDto {
return {
@@ -634,6 +715,7 @@ export class AuthService {
fullName: user.fullName,
role: user.role,
organizationId: user.organizationId,
organizationName: user.organizationName,
hasAvatar: Boolean(user.avatarStorageKey),
...(user.mobile ? { mobile: user.mobile } : {}),
};
+16
View File
@@ -107,3 +107,19 @@ export class UpdateProfileDto {
})
mobile!: string;
}
export class SelectOrganizationDto {
@IsString()
@MinLength(10)
selectionToken!: string;
@IsString()
@MinLength(1)
organizationId!: string;
}
export class SwitchOrganizationDto {
@IsString()
@MinLength(1)
organizationId!: string;
}
+18 -4
View File
@@ -24,20 +24,34 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
async validate(payload: JwtPayload & { typ?: string; mobile?: string }) {
if (payload.typ === 'portal') {
if (payload.typ === 'portal' || payload.typ === 'org-select') {
throw new UnauthorizedException();
}
if (!payload.sub || !payload.organizationId) {
throw new UnauthorizedException();
}
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: { id: true, organizationId: true, role: true, isActive: true },
select: { id: true, isActive: true },
});
if (!user || user.isActive === false) {
throw new UnauthorizedException();
}
const membership = await this.prisma.membership.findFirst({
where: {
userId: user.id,
organizationId: payload.organizationId,
isActive: true,
},
select: { organizationId: true, role: true },
});
if (!membership) {
throw new UnauthorizedException();
}
return {
userId: user.id,
organizationId: user.organizationId,
role: user.role,
organizationId: membership.organizationId,
role: membership.role,
};
}
}
@@ -350,7 +350,10 @@ export class AutomationsService {
if (wantSms) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId: ctx.organizationId },
where: {
id: userId,
memberships: { some: { organizationId: ctx.organizationId } },
},
select: { mobile: true },
});
await this.sendAutomationSms(user?.mobile, smsText);
@@ -96,10 +96,24 @@ export class ChequeRemindersService {
const staff = await this.prisma.user.findMany({
where: {
organizationId: row.organizationId,
OR: [
{ role: { in: ['owner', 'admin'] } },
...(row.ownerId ? [{ id: row.ownerId }] : []),
{
memberships: {
some: {
organizationId: row.organizationId,
role: { in: ['owner', 'admin'] },
isActive: true,
},
},
},
...(row.ownerId
? [
{
id: row.ownerId,
memberships: { some: { organizationId: row.organizationId } },
},
]
: []),
],
},
select: { id: true, mobile: true },
+11 -1
View File
@@ -75,7 +75,17 @@ export class CommissionsService {
const fixed = invoice.commissionFixedAmount;
const overridePercent = invoice.commissionPercent;
const defaultPercent = Number(invoice.owner?.commissionPercent ?? 0);
const membership = invoice.ownerId
? await this.prisma.membership.findFirst({
where: {
userId: invoice.ownerId,
organizationId,
isActive: true,
},
select: { commissionPercent: true },
})
: null;
const defaultPercent = Number(membership?.commissionPercent ?? 0);
const percent =
fixed != null
? payable > 0
@@ -242,7 +242,11 @@ export class LeadFormsService {
ownerId ??
(
await this.prisma.user.findFirst({
where: { organizationId: form.organizationId, role: 'owner' },
where: {
memberships: {
some: { organizationId: form.organizationId, role: 'owner' },
},
},
select: { id: true },
})
)?.id;
@@ -447,7 +451,10 @@ export class LeadFormsService {
) {
if (!assigneeIds?.length) return;
const count = await this.prisma.user.count({
where: { organizationId, id: { in: assigneeIds } },
where: {
id: { in: assigneeIds },
memberships: { some: { organizationId } },
},
});
if (count !== assigneeIds.length) {
throw new BadRequestException('یکی از مسئولان انتخاب‌شده معتبر نیست');
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MembershipsService } from './memberships.service';
@Module({
providers: [MembershipsService],
exports: [MembershipsService],
})
export class MembershipsModule {}
@@ -0,0 +1,161 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type { OrganizationMembershipDto, UserRole } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
export type MembershipActor = {
userId: string;
organizationId: string;
role: UserRole;
};
@Injectable()
export class MembershipsService {
constructor(private readonly prisma: PrismaService) {}
async listActive(userId: string) {
return this.prisma.membership.findMany({
where: { userId, isActive: true },
include: {
organization: { select: { id: true, name: true } },
orgRole: { select: { name: true } },
},
orderBy: { createdAt: 'asc' },
});
}
async requireActive(
userId: string,
organizationId: string,
): Promise<MembershipActor> {
const row = await this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
select: { userId: true, organizationId: true, role: true },
});
if (!row) {
throw new ForbiddenException('به این محیط دسترسی ندارید');
}
return row;
}
toChoices(
rows: Array<{
organization: { id: string; name: string };
role: UserRole;
orgRole: { name: string } | null;
}>,
): OrganizationMembershipDto[] {
return rows.map((row) => ({
id: row.organization.id,
name: row.organization.name,
role: row.role,
orgRoleName: row.orgRole?.name,
}));
}
async createMembership(data: {
userId: string;
organizationId: string;
role: UserRole;
orgRoleId?: string | null;
commissionPercent?: number;
isActive?: boolean;
}) {
return this.prisma.membership.create({
data: {
userId: data.userId,
organizationId: data.organizationId,
role: data.role,
orgRoleId: data.orgRoleId ?? null,
isActive: data.isActive ?? true,
commissionPercent:
data.commissionPercent == null
? undefined
: new Prisma.Decimal(data.commissionPercent),
},
});
}
async findInOrg(organizationId: string, userId: string) {
return this.prisma.membership.findFirst({
where: { organizationId, userId },
include: {
user: true,
orgRole: { select: { id: true, name: true, color: true } },
},
});
}
async listInOrg(organizationId: string) {
return this.prisma.membership.findMany({
where: { organizationId },
include: {
user: true,
orgRole: { select: { id: true, name: true, color: true } },
},
orderBy: [{ user: { lastLoginAt: 'desc' } }, { user: { fullName: 'asc' } }],
});
}
async countActiveOwners(organizationId: string, exceptUserId?: string) {
return this.prisma.membership.count({
where: {
organizationId,
role: 'owner',
isActive: true,
...(exceptUserId ? { userId: { not: exceptUserId } } : {}),
},
});
}
async syncUserLastOrg(
userId: string,
organizationId: string,
role: UserRole,
orgRoleId?: string | null,
) {
await this.prisma.user.update({
where: { id: userId },
data: {
organizationId,
role,
...(orgRoleId !== undefined ? { orgRoleId } : {}),
},
});
}
async findUserInOrgById(organizationId: string, userId: string) {
const row = await this.findInOrg(organizationId, userId);
if (!row) throw new NotFoundException('کاربر پیدا نشد');
return row;
}
async findUserInOrgByIdentity(
organizationId: string,
where: { id?: string; email?: string; mobile?: string },
) {
const user = await this.prisma.user.findFirst({
where: {
...(where.id ? { id: where.id } : {}),
...(where.email ? { email: where.email } : {}),
...(where.mobile ? { mobile: where.mobile } : {}),
},
select: { id: true },
});
if (!user) return null;
return this.prisma.membership.findFirst({
where: { organizationId, userId: user.id },
include: { user: true },
});
}
}
export function unauthorizedIfMissing<T>(value: T | null | undefined): T {
if (!value) throw new UnauthorizedException();
return value;
}
@@ -384,7 +384,10 @@ export class MessageAutomationsService {
}
if (userId && wantSms) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId: job.organizationId },
where: {
id: userId,
memberships: { some: { organizationId: job.organizationId } },
},
select: { mobile: true },
});
await this.sendSms(user?.mobile, smsText);
+28 -32
View File
@@ -23,13 +23,24 @@ type RoleRow = {
color: string;
permissions: string[];
systemKey: string | null;
users: Array<{
id: string;
fullName: string;
avatarStorageKey: string | null;
memberships: Array<{
user: {
id: string;
fullName: string;
avatarStorageKey: string | null;
};
}>;
};
const ROLE_MEMBERS_INCLUDE = {
memberships: {
include: {
user: { select: { id: true, fullName: true, avatarStorageKey: true } },
},
orderBy: { createdAt: 'asc' as const },
},
};
@Injectable()
export class OrgRolesService {
constructor(private readonly prisma: PrismaService) {}
@@ -38,12 +49,7 @@ export class OrgRolesService {
await this.ensureDefaultRoles(organizationId);
const rows = await this.prisma.orgRole.findMany({
where: { organizationId },
include: {
users: {
select: { id: true, fullName: true, avatarStorageKey: true },
orderBy: { fullName: 'asc' },
},
},
include: ROLE_MEMBERS_INCLUDE,
orderBy: [{ systemKey: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
@@ -73,12 +79,7 @@ export class OrgRolesService {
color: dto.color ?? 'teal',
permissions: dto.permissions?.length ? dto.permissions : [...ALL_ROLE_PERMISSIONS],
},
include: {
users: {
select: { id: true, fullName: true, avatarStorageKey: true },
orderBy: { fullName: 'asc' },
},
},
include: ROLE_MEMBERS_INCLUDE,
});
return this.toDto(row);
} catch (e) {
@@ -122,12 +123,7 @@ export class OrgRolesService {
const row = await this.prisma.orgRole.update({
where: { id: existing.id },
data,
include: {
users: {
select: { id: true, fullName: true, avatarStorageKey: true },
orderBy: { fullName: 'asc' },
},
},
include: ROLE_MEMBERS_INCLUDE,
});
return this.toDto(row);
} catch (e) {
@@ -189,15 +185,15 @@ export class OrgRolesService {
byKey.set(seed.systemKey, created);
}
const unassigned = await this.prisma.user.findMany({
const unassigned = await this.prisma.membership.findMany({
where: { organizationId, orgRoleId: null },
select: { id: true, role: true },
});
for (const user of unassigned) {
const role = byKey.get(user.role);
for (const membership of unassigned) {
const role = byKey.get(membership.role);
if (!role) continue;
await this.prisma.user.update({
where: { id: user.id },
await this.prisma.membership.update({
where: { id: membership.id },
data: { orgRoleId: role.id },
});
}
@@ -253,11 +249,11 @@ export class OrgRolesService {
color: row.color,
permissions: sanitizePermissions(row.permissions),
systemKey,
userCount: row.users.length,
members: row.users.map((user) => ({
id: user.id,
fullName: user.fullName,
hasAvatar: Boolean(user.avatarStorageKey),
userCount: row.memberships.length,
members: row.memberships.map((membership) => ({
id: membership.user.id,
fullName: membership.user.fullName,
hasAvatar: Boolean(membership.user.avatarStorageKey),
})),
};
}
+11
View File
@@ -45,6 +45,17 @@ export class UpdateOrgCurrenciesDto {
rates?: ExchangeRateInputDto[];
}
export class CreateOrganizationDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsIn([...BUSINESS_FIELDS])
businessField?: (typeof BUSINESS_FIELDS)[number];
}
export class UpdateOrganizationDto {
@IsOptional()
@IsString()
@@ -1,15 +1,32 @@
import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Patch, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard';
import { Roles } from '../auth/roles.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import { UpdateOrgCurrenciesDto, UpdateOrganizationDto } from './dto';
import { AuthService } from '../auth/auth.service';
import {
CreateOrganizationDto,
UpdateOrgCurrenciesDto,
UpdateOrganizationDto,
} from './dto';
import { OrganizationsService } from './organizations.service';
@Controller('organizations')
@UseGuards(JwtAuthGuard, RolesGuard)
export class OrganizationsController {
constructor(private readonly organizationsService: OrganizationsService) {}
constructor(
private readonly organizationsService: OrganizationsService,
private readonly authService: AuthService,
) {}
@Post()
async create(
@CurrentUser() user: { userId: string },
@Body() dto: CreateOrganizationDto,
) {
const org = await this.organizationsService.createForUser(user.userId, dto);
return this.authService.switchOrganization(user.userId, org.id);
}
@Get('current')
current(@CurrentUser() user: { organizationId: string }) {
@@ -1,8 +1,13 @@
import { Module } from '@nestjs/common';
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 { OrganizationsController } from './organizations.controller';
import { OrganizationsService } from './organizations.service';
@Module({
imports: [AuthModule, MembershipsModule, OrgRolesModule, PipelinesModule],
controllers: [OrganizationsController],
providers: [OrganizationsService],
})
@@ -2,6 +2,7 @@ import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import type {
OrganizationDto,
@@ -11,10 +12,74 @@ import { CURRENCY_CATALOG, isBusinessField } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { isKnownCurrency } from '../common/currency';
import { MembershipsService } from '../memberships/memberships.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { PipelinesService } from '../pipelines/pipelines.service';
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 OrganizationsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly memberships: MembershipsService,
private readonly orgRoles: OrgRolesService,
private readonly pipelines: PipelinesService,
) {}
async createForUser(
userId: string,
dto: { name: string; businessField?: string },
): Promise<{ id: string }> {
const name = dto.name.trim();
if (name.length < 2) {
throw new BadRequestException('نام محیط معتبر نیست');
}
const businessField =
dto.businessField && isBusinessField(dto.businessField)
? dto.businessField
: 'personal';
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, isActive: true },
});
if (!user || user.isActive === false) {
throw new UnauthorizedException();
}
const baseSlug = slugify(name);
let slug = baseSlug;
let i = 1;
while (await this.prisma.organization.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${i++}`;
}
const organization = await this.prisma.organization.create({
data: {
name,
slug,
businessField,
},
});
await this.memberships.createMembership({
userId,
organizationId: organization.id,
role: 'owner',
});
await this.orgRoles.ensureDefaultRoles(organization.id);
await this.pipelines.ensureDefaultPipeline(organization.id);
return { id: organization.id };
}
async getCurrent(organizationId: string): Promise<OrganizationDto> {
const org = await this.prisma.organization.findUnique({
@@ -311,7 +311,10 @@ export class DealsExcelService {
const n = name?.trim();
if (!n) return null;
const row = await this.prisma.user.findFirst({
where: { organizationId, fullName: { contains: n, mode: 'insensitive' } },
where: {
fullName: { contains: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
return row?.id ?? null;
+7 -2
View File
@@ -234,8 +234,13 @@ export class PortalService {
const owner =
(await this.prisma.user.findFirst({
where: {
organizationId: actor.organizationId,
role: { in: ['owner', 'admin', 'support'] },
memberships: {
some: {
organizationId: actor.organizationId,
role: { in: ['owner', 'admin', 'support'] },
isActive: true,
},
},
},
orderBy: { createdAt: 'asc' },
})) ?? null;
@@ -258,7 +258,10 @@ export class ProjectCardsExcelService {
const n = name?.trim();
if (!n) return null;
const row = await this.prisma.user.findFirst({
where: { organizationId, fullName: { contains: n, mode: 'insensitive' } },
where: {
fullName: { contains: n, mode: 'insensitive' },
memberships: { some: { organizationId } },
},
select: { id: true },
});
return row?.id ?? null;
+1 -1
View File
@@ -129,7 +129,7 @@ export class ReportsService {
select: { mood: true, createdAt: true },
}),
this.prisma.user.findMany({
where: { organizationId: orgId },
where: { memberships: { some: { organizationId: orgId } } },
select: { id: true, fullName: true },
}),
this.prisma.invoice.findMany({
+4 -1
View File
@@ -425,7 +425,10 @@ export class SalesService {
return null;
}
const user = await this.prisma.user.findFirst({
where: { id: requested, organizationId: actor.organizationId },
where: {
id: requested,
memberships: { some: { organizationId: actor.organizationId } },
},
select: { id: true },
});
if (!user) throw new BadRequestException('کاربر هدف معتبر نیست');
+1 -1
View File
@@ -432,7 +432,7 @@ export class TicketsService {
private async assertUser(organizationId: string, userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId },
where: { id: userId, memberships: { some: { organizationId } } },
select: { id: true },
});
if (!user) throw new BadRequestException('کاربر مسئول معتبر نیست');
+4 -1
View File
@@ -10,6 +10,7 @@ import {
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
const INVITE_ROLES = ['admin', 'sales', 'support'] as const;
@@ -27,10 +28,12 @@ export class InviteUserDto {
@MaxLength(120)
fullName!: string;
@IsOptional()
@ValidateIf((_, value) => typeof value === 'string' && value.length > 0)
@IsString()
@MinLength(8)
@MaxLength(72)
password!: string;
password?: string;
@IsOptional()
@IsIn(INVITE_ROLES)
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MembershipsModule } from '../memberships/memberships.module';
import { OrgRolesModule } from '../org-roles/org-roles.module';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [AuthModule, OrgRolesModule],
imports: [AuthModule, OrgRolesModule, MembershipsModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
+131 -74
View File
@@ -11,6 +11,7 @@ import { Prisma } from '@prisma/client';
import { normalizeMobile } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { OrgRolesService } from '../org-roles/org-roles.service';
import { MembershipsService } from '../memberships/memberships.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import * as path from 'path';
@@ -26,19 +27,22 @@ const AVATAR_EXT: Record<string, string> = {
gif: 'image/gif',
};
type UserRow = {
id: string;
email: string;
mobile: string | null;
fullName: string;
type MembershipRow = {
role: UserRole;
orgRoleId: string | null;
isActive: boolean;
commissionPercent: { toString(): string } | number | null;
createdAt: Date;
isActive: boolean;
lastLoginAt: Date | null;
orgRoleId: string | null;
avatarStorageKey: string | null;
orgRole: { id: string; name: string; color: string } | null;
user: {
id: string;
email: string;
mobile: string | null;
fullName: string;
lastLoginAt: Date | null;
avatarStorageKey: string | null;
organizationId: string;
};
};
@Injectable()
@@ -46,17 +50,14 @@ export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly orgRoles: OrgRolesService,
private readonly memberships: MembershipsService,
private readonly files: LocalFileStorage,
) {}
async list(organizationId: string): Promise<OrgUserDto[]> {
await this.orgRoles.ensureDefaultRoles(organizationId);
const rows = await this.prisma.user.findMany({
where: { organizationId },
include: { orgRole: { select: { id: true, name: true, color: true } } },
orderBy: [{ lastLoginAt: 'desc' }, { fullName: 'asc' }],
});
return rows.map((u) => this.toDto(u));
const rows = await this.memberships.listInOrg(organizationId);
return rows.map((row) => this.toDto(row));
}
async invite(
@@ -73,19 +74,6 @@ export class UsersService {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const existingMobile = await this.prisma.user.findFirst({ where: { mobile } });
if (existingMobile) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const email = inviteEmailFromMobile(mobile);
const existingEmail = await this.prisma.user.findFirst({
where: { organizationId, email },
});
if (existingEmail) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const resolved = await this.orgRoles.resolveInviteRole(organizationId, {
orgRoleId: dto.orgRoleId,
role: dto.role,
@@ -94,9 +82,27 @@ export class UsersService {
throw new ForbiddenException('فقط مالک می‌تواند نقش مدیر سیستم را واگذار کند');
}
const passwordHash = await bcrypt.hash(dto.password, 10);
const existingUser = await this.prisma.user.findFirst({ where: { mobile } });
if (existingUser) {
return this.addExistingUser(organizationId, existingUser.id, resolved);
}
const password = dto.password?.trim() ?? '';
if (password.length < 8) {
throw new BadRequestException(
'برای کاربر جدید رمز عبور حداقل ۸ کاراکتر لازم است',
);
}
const email = inviteEmailFromMobile(mobile);
const existingEmail = await this.prisma.user.findFirst({ where: { email } });
if (existingEmail) {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
}
const passwordHash = await bcrypt.hash(password, 10);
try {
const row = await this.prisma.user.create({
const created = await this.prisma.user.create({
data: {
organizationId,
email,
@@ -107,9 +113,14 @@ export class UsersService {
orgRoleId: resolved.orgRoleId,
isActive: true,
},
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
await this.memberships.createMembership({
userId: created.id,
organizationId,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
});
return this.dtoOf(organizationId, created.id);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این شماره موبایل قبلاً ثبت شده است');
@@ -130,16 +141,18 @@ export class UsersService {
if (percent < 0 || percent > 100) {
throw new BadRequestException('درصد پورسانت باید بین ۰ تا ۱۰۰ باشد');
}
const target = await this.prisma.user.findFirst({
where: { id: targetId, organizationId },
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
const row = await this.prisma.user.update({
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
await this.prisma.membership.update({
where: { id: target.id },
data: { commissionPercent: new Prisma.Decimal(percent) },
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
if (target.user.organizationId === organizationId) {
await this.prisma.user.update({
where: { id: target.user.id },
data: { commissionPercent: new Prisma.Decimal(percent) },
});
}
return this.dtoOf(organizationId, targetId);
}
async updateRole(
@@ -152,10 +165,7 @@ export class UsersService {
throw new ForbiddenException('فقط مالک یا مدیر می‌تواند نقش را تغییر دهد');
}
const target = await this.prisma.user.findFirst({
where: { id: targetId, organizationId },
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
let nextRole = dto.role ?? target.role;
let nextOrgRoleId = dto.orgRoleId ?? target.orgRoleId;
@@ -186,22 +196,30 @@ export class UsersService {
}
if (target.role === 'owner' && nextRole !== 'owner') {
const owners = await this.prisma.user.count({
where: { organizationId, role: 'owner' },
});
if (owners <= 1) {
const owners = await this.memberships.countActiveOwners(
organizationId,
target.userId,
);
if (owners < 1) {
throw new BadRequestException(
'حداقل یک مالک برای سازمان لازم است؛ ابتدا مالک دیگری تعیین کنید',
);
}
}
const row = await this.prisma.user.update({
await this.prisma.membership.update({
where: { id: target.id },
data: { role: nextRole, orgRoleId: nextOrgRoleId },
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
if (target.user.organizationId === organizationId) {
await this.memberships.syncUserLastOrg(
target.userId,
organizationId,
nextRole,
nextOrgRoleId,
);
}
return this.dtoOf(organizationId, targetId);
}
async updateStatus(
@@ -216,58 +234,97 @@ export class UsersService {
if (actor.userId === targetId && !isActive) {
throw new BadRequestException('نمی‌توانید حساب خودتان را غیرفعال کنید');
}
const target = await this.prisma.user.findFirst({
where: { id: targetId, organizationId },
});
if (!target) throw new NotFoundException('کاربر پیدا نشد');
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
if (target.role === 'owner' && !isActive && actor.role !== 'owner') {
throw new ForbiddenException('مدیر نمی‌تواند مالک را غیرفعال کند');
}
if (target.role === 'owner' && !isActive) {
const activeOwners = await this.prisma.user.count({
where: { organizationId, role: 'owner', isActive: true, id: { not: targetId } },
});
const activeOwners = await this.memberships.countActiveOwners(
organizationId,
targetId,
);
if (activeOwners < 1) {
throw new BadRequestException('حداقل یک مالک فعال لازم است');
}
}
const row = await this.prisma.user.update({
await this.prisma.membership.update({
where: { id: target.id },
data: { isActive },
include: { orgRole: { select: { id: true, name: true, color: true } } },
});
return this.toDto(row);
return this.dtoOf(organizationId, targetId);
}
async openAvatar(organizationId: string, userId: string) {
const user = await this.prisma.user.findFirst({
where: { id: userId, organizationId },
select: { id: true, avatarStorageKey: true },
});
if (!user?.avatarStorageKey) {
const membership = await this.memberships.findInOrg(organizationId, userId);
if (!membership) throw new NotFoundException('کاربر پیدا نشد');
const key = membership.user.avatarStorageKey;
if (!key) {
throw new NotFoundException('عکس پروفایل تنظیم نشده');
}
const ext = path.extname(user.avatarStorageKey).slice(1).toLowerCase();
const ext = path.extname(key).slice(1).toLowerCase();
const mime = AVATAR_EXT[ext] ?? 'image/jpeg';
return {
stream: this.files.openReadStream(user.avatarStorageKey),
stream: this.files.openReadStream(key),
mimeType: mime,
};
}
private toDto(row: UserRow): OrgUserDto {
private async addExistingUser(
organizationId: string,
userId: string,
resolved: { role: UserRole; orgRoleId: string },
): Promise<OrgUserDto> {
const existing = await this.prisma.membership.findFirst({
where: { userId, organizationId },
});
if (existing?.isActive) {
throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است');
}
if (existing) {
await this.prisma.membership.update({
where: { id: existing.id },
data: {
isActive: true,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
},
});
return this.dtoOf(organizationId, userId);
}
try {
await this.memberships.createMembership({
userId,
organizationId,
role: resolved.role,
orgRoleId: resolved.orgRoleId,
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('این کاربر هم‌اکنون عضو این محیط است');
}
throw e;
}
return this.dtoOf(organizationId, userId);
}
private async dtoOf(organizationId: string, userId: string): Promise<OrgUserDto> {
const row = await this.memberships.findUserInOrgById(organizationId, userId);
return this.toDto(row);
}
private toDto(row: MembershipRow & { userId?: string }): OrgUserDto {
return {
id: row.id,
email: row.email,
mobile: row.mobile ?? null,
fullName: row.fullName,
id: row.user.id,
email: row.user.email,
mobile: row.user.mobile ?? null,
fullName: row.user.fullName,
role: row.role,
orgRoleId: row.orgRoleId,
orgRoleName: row.orgRole?.name,
orgRoleColor: row.orgRole?.color ?? this.orgRoles.defaultColorFor(row.role),
isActive: row.isActive,
lastLoginAt: row.lastLoginAt ? row.lastLoginAt.toISOString() : null,
hasAvatar: Boolean(row.avatarStorageKey),
lastLoginAt: row.user.lastLoginAt ? row.user.lastLoginAt.toISOString() : null,
hasAvatar: Boolean(row.user.avatarStorageKey),
commissionPercent: Number(row.commissionPercent ?? 0),
createdAt: row.createdAt.toISOString(),
};
+352
View File
@@ -0,0 +1,352 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { USER_ROLE_LABELS } from '@/lib/roles';
import { BUSINESS_FIELDS, BUSINESS_FIELD_LABELS } from '@/lib/business-fields';
import type { BusinessField, OrganizationMembershipDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UiModal from '@/components/ui/UiModal.vue';
const props = defineProps<{
compact?: boolean;
}>();
const auth = useAuthStore();
const orgs = ref<OrganizationMembershipDto[]>([]);
const open = ref(false);
const createOpen = ref(false);
const loading = ref(false);
const creating = ref(false);
const error = ref('');
const newName = ref('');
const newField = ref<BusinessField>('personal');
const rootEl = ref<HTMLElement | null>(null);
const menuEl = ref<HTMLElement | null>(null);
const menuStyle = ref<Record<string, string>>({});
let ignoreDocClickUntil = 0;
const currentName = computed(
() => auth.user?.organizationName || orgs.value.find((o) => o.id === auth.user?.organizationId)?.name || 'محیط کاری',
);
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 loadOrgs() {
try {
orgs.value = await auth.listOrganizations();
} catch {
orgs.value = [];
}
}
async function toggle() {
error.value = '';
if (open.value) {
open.value = false;
return;
}
ignoreDocClickUntil = Date.now() + 400;
open.value = true;
await nextTick();
placeMenu();
await loadOrgs();
}
function placeMenu() {
const btn = rootEl.value?.querySelector('button');
if (!(btn instanceof HTMLElement)) return;
const box = btn.getBoundingClientRect();
menuStyle.value = {
bottom: `${Math.round(window.innerHeight - box.top + 6)}px`,
right: `${Math.round(window.innerWidth - box.right)}px`,
minWidth: `${Math.round(Math.max(box.width, 200))}px`,
};
}
async function choose(org: OrganizationMembershipDto) {
if (org.id === auth.user?.organizationId) {
open.value = false;
return;
}
loading.value = true;
error.value = '';
try {
await auth.switchOrganization(org.id);
window.location.assign('/');
} catch (e) {
error.value = apiError(e, 'تغییر محیط ناموفق بود');
loading.value = false;
}
}
function openCreate() {
open.value = false;
newName.value = '';
newField.value = 'personal';
error.value = '';
createOpen.value = true;
}
async function submitCreate() {
const name = newName.value.trim();
if (name.length < 2) {
error.value = 'نام محیط باید حداقل دو حرف باشد.';
return;
}
creating.value = true;
error.value = '';
try {
await auth.createOrganization({
name,
businessField: newField.value,
});
window.location.assign('/');
} catch (e) {
error.value = apiError(e, 'ساخت محیط ناموفق بود');
creating.value = false;
}
}
function onDocClick(event: MouseEvent) {
if (!open.value || Date.now() < ignoreDocClickUntil) return;
const target = event.target;
if (!(target instanceof Node)) return;
if (rootEl.value?.contains(target) || menuEl.value?.contains(target)) return;
open.value = false;
}
onMounted(() => {
void loadOrgs();
document.addEventListener('click', onDocClick);
});
onUnmounted(() => {
document.removeEventListener('click', onDocClick);
});
</script>
<template>
<div v-if="orgs.length > 1" ref="rootEl" class="org-switch">
<button
class="org-btn"
:class="{ compact }"
type="button"
:title="currentName"
@click.stop="toggle"
>
<UiIcon name="building" :size="18" />
<span>{{ currentName }}</span>
</button>
<Teleport to="body">
<div
v-if="open"
ref="menuEl"
class="menu"
:style="menuStyle"
role="listbox"
>
<p class="hint">ورود به محیط</p>
<button
v-for="org in orgs"
:key="org.id"
type="button"
class="org-item"
:class="{ on: org.id === auth.user?.organizationId }"
:disabled="loading"
@click="choose(org)"
>
<strong>{{ org.name }}</strong>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
</button>
<button type="button" class="create" :disabled="loading" @click="openCreate">
محیط جدید
</button>
<p v-if="error && !createOpen" class="err">{{ error }}</p>
</div>
</Teleport>
<UiModal :open="createOpen" title="محیط جدید" @close="createOpen = false">
<form class="form" @submit.prevent="submitCreate">
<label>
نام محیط
<input v-model="newName" required minlength="2" placeholder="مثلاً شرکت یا کار روزمره" />
</label>
<label>
زمینه فعالیت
<select v-model="newField">
<option v-for="field in BUSINESS_FIELDS" :key="field" :value="field">
{{ BUSINESS_FIELD_LABELS[field] }}
</option>
</select>
</label>
<p v-if="error" class="err">{{ error }}</p>
</form>
<template #footer>
<button type="button" class="ghost" @click="createOpen = false">انصراف</button>
<button type="button" class="primary" :disabled="creating" @click="submitCreate">
{{ creating ? 'در حال ساخت…' : 'ساخت و ورود' }}
</button>
</template>
</UiModal>
</div>
</template>
<style scoped>
.org-switch {
position: relative;
min-width: 0;
width: 100%;
}
.org-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
width: 100%;
border: 0;
background: transparent;
color: #94a3b8;
font: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
text-align: right;
padding: 0 0.45rem 0.15rem;
overflow: hidden;
}
.org-btn span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.org-btn:hover {
color: #0f172a;
}
.org-btn.compact {
display: grid;
place-items: center;
width: 100%;
min-height: var(--control-h);
padding: 0;
border-radius: var(--radius);
}
.org-btn.compact span {
display: none;
}
.org-btn.compact:hover {
background: #f8fafc;
}
.menu {
position: fixed;
z-index: 90;
padding: 0.45rem;
border-radius: 12px;
background: #fff;
border: 1px solid var(--line);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
display: grid;
gap: 0.2rem;
}
.hint {
margin: 0;
padding: 0.25rem 0.45rem;
font-size: 0.72rem;
color: var(--muted);
}
.org-item,
.create {
display: grid;
gap: 0.1rem;
text-align: start;
border: 0;
background: transparent;
border-radius: 9px;
padding: 0.45rem 0.5rem;
cursor: pointer;
}
.org-item strong {
font-size: 0.82rem;
}
.org-item span {
font-size: 0.72rem;
color: var(--muted);
}
.org-item:hover,
.create:hover {
background: color-mix(in srgb, var(--accent-soft) 70%, white);
}
.org-item.on {
background: var(--accent-soft);
}
.create {
margin-top: 0.15rem;
color: var(--accent);
font-weight: 700;
font-size: 0.82rem;
}
.err {
margin: 0.2rem 0 0;
color: #b91c1c;
font-size: 0.75rem;
}
.form {
display: grid;
gap: 0.75rem;
}
.form label {
display: grid;
gap: 0.35rem;
font-size: 0.88rem;
}
.form input,
.form select {
border: 1px solid var(--line);
border-radius: 10px;
padding: 0.65rem 0.75rem;
}
.ghost,
.primary {
border-radius: 10px;
padding: 0.55rem 0.9rem;
cursor: pointer;
}
.ghost {
border: 1px solid var(--line);
background: #fff;
}
.primary {
border: 0;
background: var(--accent);
color: #fff;
font-weight: 700;
}
</style>
+13 -5
View File
@@ -7,7 +7,7 @@ import { formatJalaliShort, toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import QuickCreate from '@/components/QuickCreate.vue';
import BrandName from '@/components/brand/BrandName.vue';
import OrgSwitcher from '@/components/OrgSwitcher.vue';
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
import type {
@@ -523,10 +523,13 @@ onUnmounted(() => {
</p>
</div>
<button class="logout-link" type="button" title="خروج از حساب" @click="onLogout">
<UiIcon name="logout" :size="18" />
<span>خروج از حساب</span>
</button>
<div class="foot-actions">
<OrgSwitcher :compact="collapsed" />
<button class="logout-link" type="button" title="خروج از حساب" @click="onLogout">
<UiIcon name="logout" :size="18" />
<span>خروج از حساب</span>
</button>
</div>
<button
class="collapse-btn desktop-only"
type="button"
@@ -1079,6 +1082,11 @@ onUnmounted(() => {
padding-top: 0.35rem;
}
.foot-actions {
display: grid;
gap: 0.15rem;
}
.profile-card {
display: flex;
flex-direction: column;
+42 -3
View File
@@ -2,12 +2,17 @@ import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import type {
AuthUserDto,
CreateOrganizationRequest,
LoginChallengeResponse,
LoginOrganizationChoiceResponse,
LoginResponse,
LoginResult,
OrganizationMembershipDto,
RegisterChallengeResponse,
RegisterOrganizationRequest,
UpdateProfileRequest,
} from '@mento/shared';
import { isLoginOrganizationChoice } from '@mento/shared';
import { api } from '@/lib/api';
const TOKEN_KEY = 'mento_token';
@@ -79,11 +84,11 @@ export const useAuthStore = defineStore('auth', () => {
}
async function login(identifier: string, password: string) {
const { data } = await api.post<LoginResponse>('/auth/login', {
const { data } = await api.post<LoginResult>('/auth/login', {
identifier,
password,
});
persist(data);
return applyLoginResult(data);
}
async function requestLoginOtp(mobile: string) {
@@ -94,10 +99,40 @@ export const useAuthStore = defineStore('auth', () => {
}
async function verifyLoginOtp(challengeId: string, code: string) {
const { data } = await api.post<LoginResponse>('/auth/login/otp/verify', {
const { data } = await api.post<LoginResult>('/auth/login/otp/verify', {
challengeId,
code,
});
return applyLoginResult(data);
}
function applyLoginResult(data: LoginResult): LoginOrganizationChoiceResponse | void {
if (isLoginOrganizationChoice(data)) return data;
persist(data);
}
async function selectOrganization(selectionToken: string, organizationId: string) {
const { data } = await api.post<LoginResponse>('/auth/select-organization', {
selectionToken,
organizationId,
});
persist(data);
}
async function switchOrganization(organizationId: string) {
const { data } = await api.post<LoginResponse>('/auth/switch-organization', {
organizationId,
});
persist(data);
}
async function listOrganizations() {
const { data } = await api.get<OrganizationMembershipDto[]>('/auth/organizations');
return data;
}
async function createOrganization(payload: CreateOrganizationRequest) {
const { data } = await api.post<LoginResponse>('/organizations', payload);
persist(data);
}
@@ -174,6 +209,10 @@ export const useAuthStore = defineStore('auth', () => {
login,
requestLoginOtp,
verifyLoginOtp,
selectOrganization,
switchOrganization,
listOrganizations,
createOrganization,
startRegister,
verifyRegister,
logout,
+92 -6
View File
@@ -5,7 +5,8 @@ import { useAuthStore } from '@/stores/auth';
import AuthBrandPanel from '@/components/auth/AuthBrandPanel.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import axios from 'axios';
import type { LoginChallengeResponse } from '@mento/shared';
import type { LoginChallengeResponse, LoginOrganizationChoiceResponse } from '@mento/shared';
import { USER_ROLE_LABELS } from '@/lib/roles';
import { brand } from '@mento/shared';
import BrandName from '@/components/brand/BrandName.vue';
@@ -24,6 +25,7 @@ const showPassword = ref(false);
const remember = ref(true);
const otpCode = ref('');
const challenge = ref<LoginChallengeResponse | null>(null);
const pendingChoice = ref<LoginOrganizationChoiceResponse | null>(null);
const error = ref('');
const loading = ref(false);
@@ -48,12 +50,20 @@ async function goHome() {
await router.push(redirect);
}
async function afterAuth(result: LoginOrganizationChoiceResponse | void) {
if (result) {
pendingChoice.value = result;
return;
}
await goHome();
}
async function submitPasswordLogin() {
error.value = '';
loading.value = true;
try {
await auth.login(identifier.value.trim(), password.value);
await goHome();
const result = await auth.login(identifier.value.trim(), password.value);
await afterAuth(result);
} catch (e) {
error.value = apiError(e, 'ورود ناموفق بود');
} finally {
@@ -80,8 +90,8 @@ async function submitOtpCode() {
error.value = '';
loading.value = true;
try {
await auth.verifyLoginOtp(challenge.value.challengeId, otpCode.value.trim());
await goHome();
const result = await auth.verifyLoginOtp(challenge.value.challengeId, otpCode.value.trim());
await afterAuth(result);
} catch (e) {
error.value = apiError(e, 'تأیید کد ناموفق بود');
} finally {
@@ -103,6 +113,25 @@ function backToOtpMobile() {
challenge.value = null;
error.value = '';
}
async function pickOrganization(organizationId: string) {
if (!pendingChoice.value) return;
error.value = '';
loading.value = true;
try {
await auth.selectOrganization(pendingChoice.value.selectionToken, organizationId);
await goHome();
} catch (e) {
error.value = apiError(e, 'انتخاب محیط ناموفق بود');
} finally {
loading.value = false;
}
}
function cancelOrgChoice() {
pendingChoice.value = null;
error.value = '';
}
</script>
<template>
@@ -119,7 +148,33 @@ function backToOtpMobile() {
<div class="form-inner">
<h1>خوش آمدید 👋</h1>
<p class="lead">برای ورود به حساب کاربری خود، اطلاعاتتان را وارد کنید</p>
<p class="lead">
{{
pendingChoice
? 'در کدام محیط می‌خواهید وارد شوید؟'
: 'برای ورود به حساب کاربری خود، اطلاعاتتان را وارد کنید'
}}
</p>
<template v-if="pendingChoice">
<button type="button" class="back" @click="cancelOrgChoice">بازگشت</button>
<div class="org-list">
<button
v-for="org in pendingChoice.organizations"
:key="org.id"
type="button"
class="org-card"
:disabled="loading"
@click="pickOrganization(org.id)"
>
<strong>{{ org.name }}</strong>
<span>{{ org.orgRoleName || USER_ROLE_LABELS[org.role] }}</span>
</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
</template>
<template v-else>
<div class="mode-tabs" role="tablist">
<button
@@ -257,6 +312,7 @@ function backToOtpMobile() {
حساب کاربری ندارید؟
<RouterLink to="/register">ثبت‌نام کنید</RouterLink>
</p>
</template>
</div>
</section>
</div>
@@ -503,6 +559,36 @@ h1 {
font-size: 0.88rem;
}
.org-list {
display: grid;
gap: 0.65rem;
}
.org-card {
display: grid;
gap: 0.2rem;
text-align: start;
border: 1px solid var(--line);
border-radius: 14px;
padding: 0.9rem 1rem;
background: #fff;
cursor: pointer;
}
.org-card:hover:not(:disabled) {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent-soft) 55%, white);
}
.org-card strong {
font-size: 1rem;
}
.org-card span {
color: var(--muted);
font-size: 0.82rem;
}
.auth-footer {
width: min(1040px, 100%);
margin: 0.85rem auto 0;
+14 -5
View File
@@ -296,8 +296,12 @@ function openInvite() {
}
async function submitInvite() {
if (!inviteName.value.trim() || !inviteMobile.value.trim() || invitePassword.value.length < 8) {
error.value = 'نام، شماره موبایل و رمز حداقل ۸ کاراکتر لازم است.';
if (!inviteName.value.trim() || !inviteMobile.value.trim()) {
error.value = 'نام و شماره موبایل لازم است.';
return;
}
if (invitePassword.value && invitePassword.value.length < 8) {
error.value = 'رمز عبور در صورت ورود باید حداقل ۸ کاراکتر باشد.';
return;
}
inviteSaving.value = true;
@@ -306,12 +310,12 @@ async function submitInvite() {
const payload: InviteUserRequest = {
mobile: inviteMobile.value.trim(),
fullName: inviteName.value.trim(),
password: invitePassword.value,
orgRoleId: inviteRoleId.value || undefined,
...(invitePassword.value ? { password: invitePassword.value } : {}),
};
await api.post('/users', payload);
inviteOpen.value = false;
ok.value = 'کاربر جدید اضافه شد.';
ok.value = 'کاربر به این محیط اضافه شد.';
await load();
} catch (e) {
error.value = apiError(e, 'افزودن کاربر ناموفق بود');
@@ -826,7 +830,11 @@ onUnmounted(() => {
<form class="form" @submit.prevent="submitInvite">
<label>نام و نام خانوادگی<input v-model="inviteName" required minlength="2" /></label>
<label>شماره موبایل<input v-model="inviteMobile" dir="ltr" inputmode="tel" placeholder="0912…" /></label>
<label>رمز عبور<input v-model="invitePassword" type="password" minlength="8" /></label>
<label>
رمز عبور
<input v-model="invitePassword" type="password" minlength="8" placeholder="فقط برای کاربر جدید" />
</label>
<p class="hint">اگر این شماره از قبل حساب دارد، بدون رمز به همین محیط اضافه می‌شود.</p>
<label>
نقش
<select v-model="inviteRoleId">
@@ -927,6 +935,7 @@ h1 {
.lead,
.muted,
.hint,
.empty {
margin: 0;
color: #94a3b8;
+42 -1
View File
@@ -35,6 +35,7 @@ export interface AuthUserDto {
fullName: string;
role: UserRole;
organizationId: string;
organizationName?: string;
hasAvatar?: boolean;
}
@@ -110,7 +111,7 @@ export interface UpdateOrgRoleRequest {
export interface InviteUserRequest {
mobile: string;
fullName: string;
password: string;
password?: string;
role?: Exclude<UserRole, "owner">;
orgRoleId?: string;
}
@@ -196,6 +197,46 @@ export interface LoginResponse {
user: AuthUserDto;
}
export interface OrganizationMembershipDto {
id: string;
name: string;
role: UserRole;
orgRoleName?: string;
}
export interface LoginOrganizationChoiceResponse {
requiresOrganizationChoice: true;
selectionToken: string;
organizations: OrganizationMembershipDto[];
}
export type LoginResult = LoginResponse | LoginOrganizationChoiceResponse;
export function isLoginOrganizationChoice(
data: LoginResult,
): data is LoginOrganizationChoiceResponse {
return (
typeof data === "object" &&
data !== null &&
"requiresOrganizationChoice" in data &&
data.requiresOrganizationChoice === true
);
}
export interface SelectOrganizationRequest {
selectionToken: string;
organizationId: string;
}
export interface SwitchOrganizationRequest {
organizationId: string;
}
export interface CreateOrganizationRequest {
name: string;
businessField?: BusinessField;
}
export interface OrganizationDto {
id: string;
name: string;