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
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Membership" ADD COLUMN "loginOtpOnly" BOOLEAN NOT NULL DEFAULT false;
+2
View File
@@ -382,6 +382,8 @@ model Membership {
logoutTime String? logoutTime String?
/// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست /// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست
blockLoginAfterLogout Boolean @default(false) blockLoginAfterLogout Boolean @default(false)
/// ورود به این محیط فقط با OTP؛ رمز عبور برای این عضویت کار نمی‌کند
loginOtpOnly Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+42 -10
View File
@@ -319,7 +319,7 @@ export class AuthService {
throw new UnauthorizedException('اطلاعات ورود نادرست است'); throw new UnauthorizedException('اطلاعات ورود نادرست است');
} }
return this.finishLogin(user.id); return this.finishLogin(user.id, { fromPassword: true });
} }
/** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */ /** ورود اختیاری با موبایل + OTP (بدون رمز) — فقط کاربران ثبت‌شده */
@@ -673,16 +673,20 @@ export class AuthService {
selectionToken: string, selectionToken: string,
organizationId: string, organizationId: string,
): Promise<LoginResponse> { ): Promise<LoginResponse> {
let payload: { sub?: string; typ?: string }; let payload: { sub?: string; typ?: string; pwd?: boolean };
try { try {
payload = this.jwt.verify<{ sub?: string; typ?: string }>(selectionToken); payload = this.jwt.verify<{ sub?: string; typ?: string; pwd?: boolean }>(
selectionToken,
);
} catch { } catch {
throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است'); throw new UnauthorizedException('نشست انتخاب محیط منقضی شده است');
} }
if (payload.typ !== 'org-select' || !payload.sub) { if (payload.typ !== 'org-select' || !payload.sub) {
throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است'); throw new UnauthorizedException('توکن انتخاب محیط نامعتبر است');
} }
return this.issueLogin(payload.sub, organizationId); return this.issueLogin(payload.sub, organizationId, {
fromPassword: payload.pwd === true,
});
} }
async switchOrganization( async switchOrganization(
@@ -692,30 +696,58 @@ export class AuthService {
return this.issueLogin(userId, organizationId); 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); const rows = await this.memberships.listActive(userId);
if (!rows.length) { if (!rows.length) {
throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید'); throw new UnauthorizedException('به هیچ محیطی دسترسی ندارید');
} }
if (rows.length === 1) { const usable = opts?.fromPassword
return this.issueLogin(userId, rows[0]!.organization.id); ? 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 { return {
requiresOrganizationChoice: true, requiresOrganizationChoice: true,
selectionToken: this.jwt.sign( selectionToken: this.jwt.sign(
{ sub: userId, typ: 'org-select' }, {
sub: userId,
typ: 'org-select',
...(opts?.fromPassword ? { pwd: true } : {}),
},
{ expiresIn: '10m' }, { expiresIn: '10m' },
), ),
organizations: this.memberships.toChoices(rows), organizations: this.memberships.toChoices(usable),
}; };
} }
private async issueLogin( private async issueLogin(
userId: string, userId: string,
organizationId: string, organizationId: string,
opts?: { skipLastLogin?: boolean }, opts?: { skipLastLogin?: boolean; fromPassword?: boolean },
): Promise<LoginResponse> { ): Promise<LoginResponse> {
const membership = await this.memberships.requireActive(userId, organizationId); 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 } }); const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.isActive === false) { if (!user || user.isActive === false) {
throw new UnauthorizedException('حساب کاربری غیرفعال است'); throw new UnauthorizedException('حساب کاربری غیرفعال است');
+6
View File
@@ -81,6 +81,12 @@ export class UpdateUserStatusDto {
isActive!: boolean; isActive!: boolean;
} }
export class UpdateUserLoginOtpOnlyDto {
@Type(() => Boolean)
@IsBoolean()
loginOtpOnly!: boolean;
}
export class UpdateUserCommissionDto { export class UpdateUserCommissionDto {
@Type(() => Number) @Type(() => Number)
@IsNumber() @IsNumber()
+16
View File
@@ -18,6 +18,7 @@ import {
UpdateOrgUserDto, UpdateOrgUserDto,
UpdateUserCommissionDto, UpdateUserCommissionDto,
UpdateLogoutTimeDto, UpdateLogoutTimeDto,
UpdateUserLoginOtpOnlyDto,
UpdateUserRoleDto, UpdateUserRoleDto,
UpdateUserStatusDto, UpdateUserStatusDto,
UpdateUserDataAccessDto, 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') @Patch(':id/status')
@Roles('owner', 'admin') @Roles('owner', 'admin')
updateStatus( updateStatus(
+28
View File
@@ -49,6 +49,7 @@ type MembershipRow = {
priorArchiveEnabled?: boolean; priorArchiveEnabled?: boolean;
logoutTime?: string | null; logoutTime?: string | null;
blockLoginAfterLogout?: boolean; blockLoginAfterLogout?: boolean;
loginOtpOnly?: boolean;
createdAt: Date; createdAt: Date;
orgRole: { id: string; name: string; color: string; dataAccessPolicy?: unknown } | null; orgRole: { id: string; name: string; color: string; dataAccessPolicy?: unknown } | null;
user: { user: {
@@ -454,6 +455,32 @@ export class UsersService {
return this.dtoOf(organizationId, targetId); 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( async updateStatus(
organizationId: string, organizationId: string,
actor: { userId: string; role: UserRole }, actor: { userId: string; role: UserRole },
@@ -589,6 +616,7 @@ export class UsersService {
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null, logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
blockLoginAfterLogout: blockLoginAfterLogout:
row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout), row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout),
loginOtpOnly: row.role === 'owner' ? false : Boolean(row.loginOtpOnly),
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
}; };
} }
+11 -1
View File
@@ -101,7 +101,17 @@ async function submitPasswordLogin() {
const result = await auth.login(identifier.value.trim(), password.value); const result = await auth.login(identifier.value.trim(), password.value);
await afterAuth(result); await afterAuth(result);
} catch (e) { } catch (e) {
error.value = apiError(e, 'ورود ناموفق بود'); const msg = apiError(e, 'ورود ناموفق بود');
error.value = msg;
// اگر مدیر «ورود با کد» را فعال کرده، مستقیم به تب OTP هدایت کن
if (msg.includes('ورود با کد') || msg.includes('رمز عبور برای این حساب غیرفعال')) {
const id = identifier.value.trim();
if (id && !id.includes('@')) mobile.value = id;
mode.value = 'otp';
otpStep.value = 'mobile';
challenge.value = null;
otpCode.value = '';
}
} finally { } finally {
loading.value = false; loading.value = false;
} }
+65
View File
@@ -509,6 +509,26 @@ async function toggleStatus(user: OrgUserDto) {
} }
} }
const LOGIN_OTP_ONLY_TIP =
'ورود فقط با کد پیامکی؛ رمز عبور کار نمی‌کند — حتی اگر از قبل رمز داشته باشد.';
async function toggleLoginOtpOnly(user: OrgUserDto) {
menuUserId.value = null;
if (user.role === 'owner') return;
error.value = '';
ok.value = '';
try {
const next = !user.loginOtpOnly;
await api.patch(`/users/${user.id}/login-otp-only`, { loginOtpOnly: next });
ok.value = next
? `«${user.fullName}» فقط با کد پیامکی وارد می‌شود.`
: `ورود با رمز برای «${user.fullName}» دوباره فعال شد.`;
await load();
} catch (e) {
error.value = apiError(e, 'تغییر شیوهٔ ورود ناموفق بود');
}
}
const logoutModal = ref<{ user: OrgUserDto; time: string } | null>(null); const logoutModal = ref<{ user: OrgUserDto; time: string } | null>(null);
const logoutSaving = ref(false); const logoutSaving = ref(false);
@@ -1152,6 +1172,13 @@ onUnmounted(() => {
<i /> <i />
{{ user.isActive === false ? 'غیرفعال' : 'فعال' }} {{ user.isActive === false ? 'غیرفعال' : 'فعال' }}
</span> </span>
<span
v-if="user.loginOtpOnly"
class="otp-badge"
:title="LOGIN_OTP_ONLY_TIP"
>
ورود با کد
</span>
</td> </td>
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td> <td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
<td v-if="canSetLogoutTime" @click.stop> <td v-if="canSetLogoutTime" @click.stop>
@@ -1203,6 +1230,15 @@ onUnmounted(() => {
> >
ویرایش مشخصات ویرایش مشخصات
</button> </button>
<button
v-if="canInvite && user.role !== 'owner'"
type="button"
:class="{ on: user.loginOtpOnly }"
:title="LOGIN_OTP_ONLY_TIP"
@click="toggleLoginOtpOnly(user)"
>
{{ user.loginOtpOnly ? 'لغو ورود با کد' : 'ورود با کد' }}
</button>
<button type="button" @click="toggleStatus(user)"> <button type="button" @click="toggleStatus(user)">
{{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }} {{ user.isActive === false ? 'فعال کردن' : 'غیرفعال کردن' }}
</button> </button>
@@ -1255,6 +1291,16 @@ onUnmounted(() => {
> >
ویرایش ویرایش
</button> </button>
<button
v-if="canInvite && user.role !== 'owner'"
type="button"
class="btn ghost"
:class="{ 'otp-on': user.loginOtpOnly }"
:title="LOGIN_OTP_ONLY_TIP"
@click="toggleLoginOtpOnly(user)"
>
{{ user.loginOtpOnly ? 'لغو ورود با کد' : 'ورود با کد' }}
</button>
</div> </div>
</article> </article>
<p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p> <p v-if="!loading && !pagedUsers.length" class="empty">کاربری یافت نشد.</p>
@@ -1805,6 +1851,25 @@ tbody tr + tr td {
color: #94a3b8; color: #94a3b8;
} }
.otp-badge {
display: inline-block;
margin-inline-start: 0.4rem;
padding: 0.12rem 0.45rem;
border-radius: 999px;
background: #ecfdf5;
color: #0f766e;
font-size: 0.72rem;
font-weight: 700;
vertical-align: middle;
cursor: help;
}
.btn.otp-on {
color: #0f766e;
border-color: #99f6e4;
background: #ecfdf5;
}
.ghost-ico { .ghost-ico {
width: 32px; width: 32px;
height: 32px; height: 32px;
+9
View File
@@ -389,9 +389,18 @@ export interface OrgUserDto {
logoutTime?: string | null; logoutTime?: string | null;
/** بعد از ساعت خروج تا پایان روز نمی‌تواند دوباره وارد شود */ /** بعد از ساعت خروج تا پایان روز نمی‌تواند دوباره وارد شود */
blockLoginAfterLogout?: boolean; blockLoginAfterLogout?: boolean;
/**
* ورود فقط با کد پیامکی؛ رمز عبور برای این کاربر در این محیط پذیرفته نمی‌شود
* (حتی اگر از قبل رمز داشته باشد).
*/
loginOtpOnly?: boolean;
createdAt: string; createdAt: string;
} }
export interface UpdateUserLoginOtpOnlyRequest {
loginOtpOnly: boolean;
}
export type DataVisibility = "own" | "team" | "org"; export type DataVisibility = "own" | "team" | "org";
export const DATA_VISIBILITIES: DataVisibility[] = ["own", "team", "org"]; export const DATA_VISIBILITIES: DataVisibility[] = ["own", "team", "org"];