diff --git a/apps/api/prisma/migrations/20261022160000_membership_login_otp_only/migration.sql b/apps/api/prisma/migrations/20261022160000_membership_login_otp_only/migration.sql new file mode 100644 index 0000000..46fd7ee --- /dev/null +++ b/apps/api/prisma/migrations/20261022160000_membership_login_otp_only/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Membership" ADD COLUMN "loginOtpOnly" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 98e6f5b..7afcd02 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -382,6 +382,8 @@ model Membership { logoutTime String? /// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست blockLoginAfterLogout Boolean @default(false) + /// ورود به این محیط فقط با OTP؛ رمز عبور برای این عضویت کار نمی‌کند + loginOtpOnly Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 326a918..d510313 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { - 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 { + private async finishLogin( + userId: string, + opts?: { fromPassword?: boolean }, + ): Promise { 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 { 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('حساب کاربری غیرفعال است'); diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index 5be1f1c..19a2ddc 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -81,6 +81,12 @@ export class UpdateUserStatusDto { isActive!: boolean; } +export class UpdateUserLoginOtpOnlyDto { + @Type(() => Boolean) + @IsBoolean() + loginOtpOnly!: boolean; +} + export class UpdateUserCommissionDto { @Type(() => Number) @IsNumber() diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 9d7943c..2691ca0 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -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( diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 24bb95d..84a37cd 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 { + 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(), }; } diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue index ee122a1..86b1bc4 100644 --- a/apps/web/src/views/LoginView.vue +++ b/apps/web/src/views/LoginView.vue @@ -101,7 +101,17 @@ async function submitPasswordLogin() { const result = await auth.login(identifier.value.trim(), password.value); await afterAuth(result); } 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 { loading.value = false; } diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index 020f514..da4ab98 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -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 logoutSaving = ref(false); @@ -1152,6 +1172,13 @@ onUnmounted(() => { {{ user.isActive === false ? 'غیرفعال' : 'فعال' }} + + ورود با کد + {{ formatLastLogin(user.lastLoginAt) }} @@ -1203,6 +1230,15 @@ onUnmounted(() => { > ویرایش مشخصات + @@ -1255,6 +1291,16 @@ onUnmounted(() => { > ویرایش +

کاربری یافت نشد.

@@ -1805,6 +1851,25 @@ tbody tr + tr td { 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 { width: 32px; height: 32px; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 643ce49..4f65a7d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -389,9 +389,18 @@ export interface OrgUserDto { logoutTime?: string | null; /** بعد از ساعت خروج تا پایان روز نمی‌تواند دوباره وارد شود */ blockLoginAfterLogout?: boolean; + /** + * ورود فقط با کد پیامکی؛ رمز عبور برای این کاربر در این محیط پذیرفته نمی‌شود + * (حتی اگر از قبل رمز داشته باشد). + */ + loginOtpOnly?: boolean; createdAt: string; } +export interface UpdateUserLoginOtpOnlyRequest { + loginOtpOnly: boolean; +} + export type DataVisibility = "own" | "team" | "org"; export const DATA_VISIBILITIES: DataVisibility[] = ["own", "team", "org"];