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
+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 } : {}),
};