Multi-environment and multi-user construction
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user