feat(auth): allow owner to force OTP-only login per user
Team menu «ورود با کد» blocks password login with a clear message and switches the login screen to OTP.
This commit is contained in:
@@ -319,7 +319,7 @@ export class AuthService {
|
||||
throw new UnauthorizedException('اطلاعات ورود نادرست است');
|
||||
}
|
||||
|
||||
return this.finishLogin(user.id);
|
||||
return this.finishLogin(user.id, { fromPassword: true });
|
||||
}
|
||||
|
||||
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبتشده */
|
||||
@@ -673,16 +673,20 @@ export class AuthService {
|
||||
selectionToken: string,
|
||||
organizationId: string,
|
||||
): Promise<LoginResponse> {
|
||||
let payload: { sub?: string; typ?: string };
|
||||
let payload: { sub?: string; typ?: string; pwd?: boolean };
|
||||
try {
|
||||
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken);
|
||||
payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean }>(
|
||||
selectionToken,
|
||||
);
|
||||
} catch {
|
||||
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
|
||||
}
|
||||
if (payload.typ !== 'org-select' || !payload.sub) {
|
||||
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
|
||||
}
|
||||
return this.issueLogin(payload.sub, organizationId);
|
||||
return this.issueLogin(payload.sub, organizationId, {
|
||||
fromPassword: payload.pwd === true,
|
||||
});
|
||||
}
|
||||
|
||||
async switchOrganization(
|
||||
@@ -692,30 +696,58 @@ export class AuthService {
|
||||
return this.issueLogin(userId, organizationId);
|
||||
}
|
||||
|
||||
private async finishLogin(userId: string): Promise<LoginResult> {
|
||||
private async finishLogin(
|
||||
userId: string,
|
||||
opts?: { fromPassword?: boolean },
|
||||
): 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);
|
||||
const usable = opts?.fromPassword
|
||||
? rows.filter((row) => !row.loginOtpOnly)
|
||||
: rows;
|
||||
if (!usable.length) {
|
||||
throw new UnauthorizedException(
|
||||
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
||||
);
|
||||
}
|
||||
if (usable.length === 1) {
|
||||
return this.issueLogin(userId, usable[0]!.organization.id, {
|
||||
fromPassword: opts?.fromPassword,
|
||||
});
|
||||
}
|
||||
return {
|
||||
requiresOrganizationChoice: true,
|
||||
selectionToken: this.jwt.sign(
|
||||
{ sub: userId, typ: 'org-select' },
|
||||
{
|
||||
sub: userId,
|
||||
typ: 'org-select',
|
||||
...(opts?.fromPassword ? { pwd: true } : {}),
|
||||
},
|
||||
{ expiresIn: '10m' },
|
||||
),
|
||||
organizations: this.memberships.toChoices(rows),
|
||||
organizations: this.memberships.toChoices(usable),
|
||||
};
|
||||
}
|
||||
|
||||
private async issueLogin(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
opts?: { skipLastLogin?: boolean },
|
||||
opts?: { skipLastLogin?: boolean; fromPassword?: boolean },
|
||||
): Promise<LoginResponse> {
|
||||
const membership = await this.memberships.requireActive(userId, organizationId);
|
||||
if (opts?.fromPassword) {
|
||||
const gate = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId, isActive: true },
|
||||
select: { loginOtpOnly: true },
|
||||
});
|
||||
if (gate?.loginOtpOnly) {
|
||||
throw new UnauthorizedException(
|
||||
'رمز عبور برای این حساب غیرفعال است. مدیر ورود را روی «ورود با کد» گذاشته؛ از تب ورود با کد (پیامک) وارد شوید.',
|
||||
);
|
||||
}
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.isActive === false) {
|
||||
throw new UnauthorizedException('حساب کاربری غیرفعال است');
|
||||
|
||||
@@ -81,6 +81,12 @@ export class UpdateUserStatusDto {
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserLoginOtpOnlyDto {
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
loginOtpOnly!: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserCommissionDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
UpdateOrgUserDto,
|
||||
UpdateUserCommissionDto,
|
||||
UpdateLogoutTimeDto,
|
||||
UpdateUserLoginOtpOnlyDto,
|
||||
UpdateUserRoleDto,
|
||||
UpdateUserStatusDto,
|
||||
UpdateUserDataAccessDto,
|
||||
@@ -99,6 +100,21 @@ export class UsersController {
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/login-otp-only')
|
||||
@Roles('owner', 'admin')
|
||||
updateLoginOtpOnly(
|
||||
@CurrentUser() user: { organizationId: string; userId: string; role: UserRole },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserLoginOtpOnlyDto,
|
||||
) {
|
||||
return this.usersService.updateLoginOtpOnly(
|
||||
user.organizationId,
|
||||
{ userId: user.userId, role: user.role },
|
||||
id,
|
||||
dto.loginOtpOnly,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@Roles('owner', 'admin')
|
||||
updateStatus(
|
||||
|
||||
@@ -49,6 +49,7 @@ type MembershipRow = {
|
||||
priorArchiveEnabled?: boolean;
|
||||
logoutTime?: string | null;
|
||||
blockLoginAfterLogout?: boolean;
|
||||
loginOtpOnly?: boolean;
|
||||
createdAt: Date;
|
||||
orgRole: { id: string; name: string; color: string; dataAccessPolicy?: unknown } | null;
|
||||
user: {
|
||||
@@ -454,6 +455,32 @@ export class UsersService {
|
||||
return this.dtoOf(organizationId, targetId);
|
||||
}
|
||||
|
||||
async updateLoginOtpOnly(
|
||||
organizationId: string,
|
||||
actor: { userId: string; role: UserRole },
|
||||
targetId: string,
|
||||
loginOtpOnly: boolean,
|
||||
): Promise<OrgUserDto> {
|
||||
if (actor.role !== 'owner' && actor.role !== 'admin') {
|
||||
throw new ForbiddenException('فقط مالک یا مدیر میتواند شیوهٔ ورود را تغییر دهد');
|
||||
}
|
||||
const target = await this.memberships.findUserInOrgById(organizationId, targetId);
|
||||
if (target.role === 'owner') {
|
||||
throw new BadRequestException('برای مالک نمیتوان ورود فقط با کد را فعال کرد');
|
||||
}
|
||||
if (loginOtpOnly) {
|
||||
const mobile = target.user.mobile?.trim();
|
||||
if (!mobile) {
|
||||
throw new BadRequestException('برای ورود با کد، کاربر باید موبایل داشته باشد');
|
||||
}
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
where: { id: target.id },
|
||||
data: { loginOtpOnly },
|
||||
});
|
||||
return this.dtoOf(organizationId, targetId);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
organizationId: string,
|
||||
actor: { userId: string; role: UserRole },
|
||||
@@ -589,6 +616,7 @@ export class UsersService {
|
||||
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
|
||||
blockLoginAfterLogout:
|
||||
row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout),
|
||||
loginOtpOnly: row.role === 'owner' ? false : Boolean(row.loginOtpOnly),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user