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
+42 -10
View File
@@ -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('حساب کاربری غیرفعال است');