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:
2026-10-22 15:43:24 +03:30
parent 80fb5bf1bb
commit 65cf4e9a66
9 changed files with 181 additions and 11 deletions
+6
View File
@@ -81,6 +81,12 @@ export class UpdateUserStatusDto {
isActive!: boolean;
}
export class UpdateUserLoginOtpOnlyDto {
@Type(() => Boolean)
@IsBoolean()
loginOtpOnly!: boolean;
}
export class UpdateUserCommissionDto {
@Type(() => Number)
@IsNumber()
+16
View File
@@ -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(
+28
View File
@@ -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(),
};
}