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