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:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Membership" ADD COLUMN "loginOtpOnly" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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('حساب کاربری غیرفعال است');
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
Reference in New Issue
Block a user