Multi-environment and multi-user construction
This commit is contained in:
@@ -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"
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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('مسئول انتخابشده معتبر نیست');
|
||||
|
||||
@@ -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,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],
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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('کاربر هدف معتبر نیست');
|
||||
|
||||
@@ -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('کاربر مسئول معتبر نیست');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user