feat: compact logout-time cell and optional login block after logout time
Owners set the daily logout time in a modal and can block re-login until the next Tehran day.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Membership" ADD COLUMN "blockLoginAfterLogout" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -375,6 +375,8 @@ model Membership {
|
||||
priorArchiveLens String @default("current")
|
||||
/// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت
|
||||
logoutTime String?
|
||||
/// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست
|
||||
blockLoginAfterLogout Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
export const WORKDAY_ENDED_MESSAGE =
|
||||
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
|
||||
|
||||
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
|
||||
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
|
||||
|
||||
type RecordInput = {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
@@ -61,6 +64,18 @@ export class AttendanceService {
|
||||
});
|
||||
}
|
||||
|
||||
/** با «مسدودسازی بعد از ساعت خروج»، از آن ساعت تا پایان روز تهران ورود رد میشود. */
|
||||
async assertLoginAllowed(organizationId: string, userId: string, at = new Date()) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { organizationId, userId, isActive: true },
|
||||
select: { role: true, logoutTime: true, blockLoginAfterLogout: true },
|
||||
});
|
||||
if (!membership || membership.role === 'owner' || !membership.blockLoginAfterLogout) return;
|
||||
if (loggedInAfterCutoff(at, membership.logoutTime)) {
|
||||
throw new ForbiddenException(LOGIN_BLOCKED_AFTER_HOURS_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
async recordLogout(actor: Actor) {
|
||||
if (actor.viewAsReadOnly) return { ok: true };
|
||||
const at = new Date();
|
||||
@@ -81,14 +96,20 @@ export class AttendanceService {
|
||||
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
||||
select: { logoutTime: true, role: true },
|
||||
select: { logoutTime: true, role: true, blockLoginAfterLogout: true },
|
||||
});
|
||||
const logoutTime = membership?.logoutTime;
|
||||
if (!logoutTime || membership?.role === 'owner') return;
|
||||
const now = new Date();
|
||||
const cutoff = tehranCutoff(now, logoutTime);
|
||||
if (!cutoff || now.getTime() < cutoff.getTime()) return;
|
||||
if (actor.sessionStartedAt.getTime() >= cutoff.getTime()) return;
|
||||
// نشستی که بعد از ساعت خروج شروع شده فقط وقتی مجاز است که ورود بعد از ساعت مسدود نباشد
|
||||
if (
|
||||
actor.sessionStartedAt.getTime() >= cutoff.getTime() &&
|
||||
!membership?.blockLoginAfterLogout
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.write({
|
||||
organizationId: actor.organizationId,
|
||||
|
||||
@@ -723,6 +723,9 @@ export class AuthService {
|
||||
const subscription = await this.billing.requireActiveForOrganization(
|
||||
organizationId,
|
||||
);
|
||||
if (!opts?.skipLastLogin) {
|
||||
await this.attendance.assertLoginAllowed(organizationId, userId);
|
||||
}
|
||||
await this.memberships.syncUserLastOrg(
|
||||
userId,
|
||||
organizationId,
|
||||
|
||||
@@ -95,6 +95,11 @@ export class UpdateLogoutTimeDto {
|
||||
@IsString()
|
||||
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'ساعت خروج معتبر نیست' })
|
||||
logoutTime?: string | null;
|
||||
|
||||
/** بدون مقدار = بدون تغییر */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
blockLoginAfterLogout?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateUserVisibilityDto {
|
||||
|
||||
@@ -79,6 +79,7 @@ export class UsersController {
|
||||
user.role,
|
||||
id,
|
||||
dto.logoutTime ?? null,
|
||||
dto.blockLoginAfterLogout,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ type MembershipRow = {
|
||||
dataVisibilityScopes?: unknown;
|
||||
priorArchiveEnabled?: boolean;
|
||||
logoutTime?: string | null;
|
||||
blockLoginAfterLogout?: boolean;
|
||||
createdAt: Date;
|
||||
orgRole: { id: string; name: string; color: string } | null;
|
||||
user: {
|
||||
@@ -248,6 +249,7 @@ export class UsersService {
|
||||
actorRole: UserRole,
|
||||
targetId: string,
|
||||
logoutTime: string | null,
|
||||
blockLoginAfterLogout?: boolean,
|
||||
): Promise<OrgUserDto> {
|
||||
if (actorRole !== 'owner') {
|
||||
throw new ForbiddenException('فقط مالک میتواند ساعت خروج را تعیین کند');
|
||||
@@ -263,7 +265,15 @@ export class UsersService {
|
||||
}
|
||||
await this.prisma.membership.update({
|
||||
where: { id: target.id },
|
||||
data: { logoutTime: value },
|
||||
data: {
|
||||
logoutTime: value,
|
||||
// بدون ساعت خروج، مسدودسازی معنی ندارد
|
||||
...(!value
|
||||
? { blockLoginAfterLogout: false }
|
||||
: blockLoginAfterLogout !== undefined
|
||||
? { blockLoginAfterLogout }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return this.dtoOf(organizationId, targetId);
|
||||
}
|
||||
@@ -533,6 +543,8 @@ export class UsersService {
|
||||
: parseVisibilityScopes(row.dataVisibilityScopes),
|
||||
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
|
||||
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
|
||||
blockLoginAfterLogout:
|
||||
row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useEscClose } from '@/lib/modal-stack';
|
||||
import { matchesSearch } from '@/lib/search';
|
||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
@@ -504,20 +505,54 @@ async function toggleStatus(user: OrgUserDto) {
|
||||
}
|
||||
}
|
||||
|
||||
const logoutModal = ref<{ user: OrgUserDto; time: string } | null>(null);
|
||||
const logoutSaving = ref(false);
|
||||
|
||||
function openLogoutModal(user: OrgUserDto) {
|
||||
if (!canSetLogoutTime.value || user.role === 'owner') return;
|
||||
logoutModal.value = { user, time: user.logoutTime || '18:00' };
|
||||
}
|
||||
|
||||
async function changeLogoutTime(user: OrgUserDto, raw: string) {
|
||||
if (!canSetLogoutTime.value || user.role === 'owner') return;
|
||||
const next = raw.trim();
|
||||
if (next === (user.logoutTime ?? '')) return;
|
||||
if (next === (user.logoutTime ?? '')) {
|
||||
logoutModal.value = null;
|
||||
return;
|
||||
}
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
logoutSaving.value = true;
|
||||
try {
|
||||
await api.patch(`/users/${user.id}/logout-time`, { logoutTime: next || null });
|
||||
ok.value = next
|
||||
? `ساعت خروج «${user.fullName}» روی ${toFaDigits(next)} تنظیم شد.`
|
||||
: `محدودیت خروج «${user.fullName}» برداشته شد.`;
|
||||
logoutModal.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره ساعت خروج ناموفق بود');
|
||||
} finally {
|
||||
logoutSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBlockAfterLogout(user: OrgUserDto) {
|
||||
if (!canSetLogoutTime.value || user.role === 'owner' || !user.logoutTime) return;
|
||||
const next = !user.blockLoginAfterLogout;
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
try {
|
||||
await api.patch(`/users/${user.id}/logout-time`, {
|
||||
logoutTime: user.logoutTime,
|
||||
blockLoginAfterLogout: next,
|
||||
});
|
||||
ok.value = next
|
||||
? `«${user.fullName}» بعد از ساعت ${toFaDigits(user.logoutTime)} دیگر نمیتواند وارد شود.`
|
||||
: `«${user.fullName}» بعد از ساعت خروج هم میتواند وارد شود.`;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'تغییر محدودیت ورود ناموفق بود');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1051,15 +1086,34 @@ onUnmounted(() => {
|
||||
<td class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
|
||||
<td v-if="canSetLogoutTime" @click.stop>
|
||||
<span v-if="user.role === 'owner'" class="muted">—</span>
|
||||
<input
|
||||
v-else
|
||||
class="logout-time"
|
||||
type="time"
|
||||
dir="ltr"
|
||||
:value="user.logoutTime || ''"
|
||||
:aria-label="`ساعت خروج ${user.fullName}`"
|
||||
@change="changeLogoutTime(user, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<span v-else class="logout-cell">
|
||||
<button
|
||||
type="button"
|
||||
class="logout-btn"
|
||||
:class="{ set: Boolean(user.logoutTime) }"
|
||||
:aria-label="user.logoutTime ? `ویرایش ساعت خروج ${user.fullName}` : `تعیین ساعت خروج ${user.fullName}`"
|
||||
:title="user.logoutTime ? 'ویرایش ساعت خروج' : 'تعیین ساعت خروج'"
|
||||
@click="openLogoutModal(user)"
|
||||
>
|
||||
<span v-if="user.logoutTime" dir="ltr">{{ toFaDigits(user.logoutTime) }}</span>
|
||||
<UiIcon v-else name="clock" :size="16" />
|
||||
</button>
|
||||
<button
|
||||
v-if="user.logoutTime"
|
||||
type="button"
|
||||
class="block-btn"
|
||||
:class="{ on: user.blockLoginAfterLogout }"
|
||||
:aria-pressed="Boolean(user.blockLoginAfterLogout)"
|
||||
:title="
|
||||
user.blockLoginAfterLogout
|
||||
? 'ورود بعد از ساعت خروج مسدود است — برای آزاد کردن بزنید'
|
||||
: 'ورود بعد از ساعت خروج آزاد است — برای مسدود کردن بزنید'
|
||||
"
|
||||
@click="toggleBlockAfterLogout(user)"
|
||||
>
|
||||
<UiIcon :name="user.blockLoginAfterLogout ? 'check' : 'x'" :size="14" />
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
<td class="ops" @click.stop>
|
||||
<UiDropdownMenu
|
||||
@@ -1205,6 +1259,43 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal
|
||||
:open="Boolean(logoutModal)"
|
||||
:title="logoutModal ? `ساعت خروج ${logoutModal.user.fullName}` : 'ساعت خروج'"
|
||||
@close="logoutModal = null"
|
||||
>
|
||||
<form v-if="logoutModal" class="form" @submit.prevent="changeLogoutTime(logoutModal.user, logoutModal.time)">
|
||||
<label>
|
||||
ساعت خروج روزانه
|
||||
<JalaliDateInput v-model="logoutModal.time" mode="time" :clearable="false" />
|
||||
</label>
|
||||
<p class="hint">
|
||||
در این ساعت (به وقت تهران) کاربر خودکار از سامانه خارج میشود. با تیک کنار ساعت میتوانید
|
||||
ورود دوباره تا پایان همان روز را هم ببندید.
|
||||
</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<button
|
||||
v-if="logoutModal?.user.logoutTime"
|
||||
type="button"
|
||||
class="btn ghost"
|
||||
:disabled="logoutSaving"
|
||||
@click="logoutModal && changeLogoutTime(logoutModal.user, '')"
|
||||
>
|
||||
حذف ساعت خروج
|
||||
</button>
|
||||
<button type="button" class="btn ghost" @click="logoutModal = null">انصراف</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
:disabled="logoutSaving || !logoutModal?.time"
|
||||
@click="logoutModal && changeLogoutTime(logoutModal.user, logoutModal.time)"
|
||||
>
|
||||
{{ logoutSaving ? 'در حال ذخیره…' : 'ذخیره' }}
|
||||
</button>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal :open="editOpen" title="ویرایش مشخصات کاربر" @close="editOpen = false">
|
||||
<form class="form" @submit.prevent="submitEditUser">
|
||||
<label>نام و نام خانوادگی<input v-model="editName" required minlength="2" /></label>
|
||||
@@ -1875,22 +1966,53 @@ tbody tr + tr td {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.logout-time {
|
||||
width: 7.2rem;
|
||||
min-height: 2rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 0 0.45rem;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
.logout-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.logout-time:disabled {
|
||||
background: #f8fafc;
|
||||
.logout-btn,
|
||||
.block-btn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
height: 1.9rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #64748b;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
min-width: 1.9rem;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.logout-btn.set {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.logout-btn:hover,
|
||||
.block-btn:hover {
|
||||
border-color: #0d9488;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.block-btn {
|
||||
width: 1.9rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.block-btn.on {
|
||||
border-color: #99f6e4;
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.roles-grid,
|
||||
.roles-tree {
|
||||
display: grid;
|
||||
|
||||
@@ -378,6 +378,8 @@ export interface OrgUserDto {
|
||||
priorArchiveEnabled?: boolean;
|
||||
/** ساعت خروج روزانه HH:mm به وقت تهران؛ خالی یعنی بدون محدودیت */
|
||||
logoutTime?: string | null;
|
||||
/** بعد از ساعت خروج تا پایان روز نمیتواند دوباره وارد شود */
|
||||
blockLoginAfterLogout?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user