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")
|
priorArchiveLens String @default("current")
|
||||||
/// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت
|
/// ساعت خروج روزانه به وقت تهران، مثل 18:00؛ خالی یعنی بدون محدودیت
|
||||||
logoutTime String?
|
logoutTime String?
|
||||||
|
/// بعد از ساعت خروج تا پایان روز (تهران) اجازهٔ ورود دوباره به این محیط نیست
|
||||||
|
blockLoginAfterLogout Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 { Prisma } from '@prisma/client';
|
||||||
import type { Actor } from '../common/record-scope';
|
import type { Actor } from '../common/record-scope';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
export const WORKDAY_ENDED_MESSAGE =
|
export const WORKDAY_ENDED_MESSAGE =
|
||||||
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
|
'ساعت کاری امروز تمام شده است. برای ادامه دوباره وارد شوید.';
|
||||||
|
|
||||||
|
export const LOGIN_BLOCKED_AFTER_HOURS_MESSAGE =
|
||||||
|
'ساعت کاری امروز شما تمام شده و ورود دوباره تا فردا مجاز نیست.';
|
||||||
|
|
||||||
type RecordInput = {
|
type RecordInput = {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
userId: 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) {
|
async recordLogout(actor: Actor) {
|
||||||
if (actor.viewAsReadOnly) return { ok: true };
|
if (actor.viewAsReadOnly) return { ok: true };
|
||||||
const at = new Date();
|
const at = new Date();
|
||||||
@@ -81,14 +96,20 @@ export class AttendanceService {
|
|||||||
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
|
if (actor.viewAsReadOnly || actor.role === 'owner' || !actor.sessionStartedAt) return;
|
||||||
const membership = await this.prisma.membership.findFirst({
|
const membership = await this.prisma.membership.findFirst({
|
||||||
where: { userId: actor.userId, organizationId: actor.organizationId, isActive: true },
|
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;
|
const logoutTime = membership?.logoutTime;
|
||||||
if (!logoutTime || membership?.role === 'owner') return;
|
if (!logoutTime || membership?.role === 'owner') return;
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const cutoff = tehranCutoff(now, logoutTime);
|
const cutoff = tehranCutoff(now, logoutTime);
|
||||||
if (!cutoff || now.getTime() < cutoff.getTime()) return;
|
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({
|
await this.write({
|
||||||
organizationId: actor.organizationId,
|
organizationId: actor.organizationId,
|
||||||
|
|||||||
@@ -723,6 +723,9 @@ export class AuthService {
|
|||||||
const subscription = await this.billing.requireActiveForOrganization(
|
const subscription = await this.billing.requireActiveForOrganization(
|
||||||
organizationId,
|
organizationId,
|
||||||
);
|
);
|
||||||
|
if (!opts?.skipLastLogin) {
|
||||||
|
await this.attendance.assertLoginAllowed(organizationId, userId);
|
||||||
|
}
|
||||||
await this.memberships.syncUserLastOrg(
|
await this.memberships.syncUserLastOrg(
|
||||||
userId,
|
userId,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ export class UpdateLogoutTimeDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'ساعت خروج معتبر نیست' })
|
@Matches(/^([01]\d|2[0-3]):[0-5]\d$/, { message: 'ساعت خروج معتبر نیست' })
|
||||||
logoutTime?: string | null;
|
logoutTime?: string | null;
|
||||||
|
|
||||||
|
/** بدون مقدار = بدون تغییر */
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
blockLoginAfterLogout?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateUserVisibilityDto {
|
export class UpdateUserVisibilityDto {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export class UsersController {
|
|||||||
user.role,
|
user.role,
|
||||||
id,
|
id,
|
||||||
dto.logoutTime ?? null,
|
dto.logoutTime ?? null,
|
||||||
|
dto.blockLoginAfterLogout,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type MembershipRow = {
|
|||||||
dataVisibilityScopes?: unknown;
|
dataVisibilityScopes?: unknown;
|
||||||
priorArchiveEnabled?: boolean;
|
priorArchiveEnabled?: boolean;
|
||||||
logoutTime?: string | null;
|
logoutTime?: string | null;
|
||||||
|
blockLoginAfterLogout?: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
orgRole: { id: string; name: string; color: string } | null;
|
orgRole: { id: string; name: string; color: string } | null;
|
||||||
user: {
|
user: {
|
||||||
@@ -248,6 +249,7 @@ export class UsersService {
|
|||||||
actorRole: UserRole,
|
actorRole: UserRole,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
logoutTime: string | null,
|
logoutTime: string | null,
|
||||||
|
blockLoginAfterLogout?: boolean,
|
||||||
): Promise<OrgUserDto> {
|
): Promise<OrgUserDto> {
|
||||||
if (actorRole !== 'owner') {
|
if (actorRole !== 'owner') {
|
||||||
throw new ForbiddenException('فقط مالک میتواند ساعت خروج را تعیین کند');
|
throw new ForbiddenException('فقط مالک میتواند ساعت خروج را تعیین کند');
|
||||||
@@ -263,7 +265,15 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
await this.prisma.membership.update({
|
await this.prisma.membership.update({
|
||||||
where: { id: target.id },
|
where: { id: target.id },
|
||||||
data: { logoutTime: value },
|
data: {
|
||||||
|
logoutTime: value,
|
||||||
|
// بدون ساعت خروج، مسدودسازی معنی ندارد
|
||||||
|
...(!value
|
||||||
|
? { blockLoginAfterLogout: false }
|
||||||
|
: blockLoginAfterLogout !== undefined
|
||||||
|
? { blockLoginAfterLogout }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return this.dtoOf(organizationId, targetId);
|
return this.dtoOf(organizationId, targetId);
|
||||||
}
|
}
|
||||||
@@ -533,6 +543,8 @@ export class UsersService {
|
|||||||
: parseVisibilityScopes(row.dataVisibilityScopes),
|
: parseVisibilityScopes(row.dataVisibilityScopes),
|
||||||
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
|
priorArchiveEnabled: Boolean(row.priorArchiveEnabled),
|
||||||
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
|
logoutTime: row.role === 'owner' ? null : row.logoutTime ?? null,
|
||||||
|
blockLoginAfterLogout:
|
||||||
|
row.role !== 'owner' && Boolean(row.logoutTime) && Boolean(row.blockLoginAfterLogout),
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
|||||||
import { useEscClose } from '@/lib/modal-stack';
|
import { useEscClose } from '@/lib/modal-stack';
|
||||||
import { matchesSearch } from '@/lib/search';
|
import { matchesSearch } from '@/lib/search';
|
||||||
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
||||||
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { api } from '@/lib/api';
|
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) {
|
async function changeLogoutTime(user: OrgUserDto, raw: string) {
|
||||||
if (!canSetLogoutTime.value || user.role === 'owner') return;
|
if (!canSetLogoutTime.value || user.role === 'owner') return;
|
||||||
const next = raw.trim();
|
const next = raw.trim();
|
||||||
if (next === (user.logoutTime ?? '')) return;
|
if (next === (user.logoutTime ?? '')) {
|
||||||
|
logoutModal.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
error.value = '';
|
error.value = '';
|
||||||
ok.value = '';
|
ok.value = '';
|
||||||
|
logoutSaving.value = true;
|
||||||
try {
|
try {
|
||||||
await api.patch(`/users/${user.id}/logout-time`, { logoutTime: next || null });
|
await api.patch(`/users/${user.id}/logout-time`, { logoutTime: next || null });
|
||||||
ok.value = next
|
ok.value = next
|
||||||
? `ساعت خروج «${user.fullName}» روی ${toFaDigits(next)} تنظیم شد.`
|
? `ساعت خروج «${user.fullName}» روی ${toFaDigits(next)} تنظیم شد.`
|
||||||
: `محدودیت خروج «${user.fullName}» برداشته شد.`;
|
: `محدودیت خروج «${user.fullName}» برداشته شد.`;
|
||||||
|
logoutModal.value = null;
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = apiError(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 class="muted">{{ formatLastLogin(user.lastLoginAt) }}</td>
|
||||||
<td v-if="canSetLogoutTime" @click.stop>
|
<td v-if="canSetLogoutTime" @click.stop>
|
||||||
<span v-if="user.role === 'owner'" class="muted">—</span>
|
<span v-if="user.role === 'owner'" class="muted">—</span>
|
||||||
<input
|
<span v-else class="logout-cell">
|
||||||
v-else
|
<button
|
||||||
class="logout-time"
|
type="button"
|
||||||
type="time"
|
class="logout-btn"
|
||||||
dir="ltr"
|
:class="{ set: Boolean(user.logoutTime) }"
|
||||||
:value="user.logoutTime || ''"
|
:aria-label="user.logoutTime ? `ویرایش ساعت خروج ${user.fullName}` : `تعیین ساعت خروج ${user.fullName}`"
|
||||||
:aria-label="`ساعت خروج ${user.fullName}`"
|
:title="user.logoutTime ? 'ویرایش ساعت خروج' : 'تعیین ساعت خروج'"
|
||||||
@change="changeLogoutTime(user, ($event.target as HTMLInputElement).value)"
|
@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>
|
||||||
<td class="ops" @click.stop>
|
<td class="ops" @click.stop>
|
||||||
<UiDropdownMenu
|
<UiDropdownMenu
|
||||||
@@ -1205,6 +1259,43 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</UiModal>
|
</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">
|
<UiModal :open="editOpen" title="ویرایش مشخصات کاربر" @close="editOpen = false">
|
||||||
<form class="form" @submit.prevent="submitEditUser">
|
<form class="form" @submit.prevent="submitEditUser">
|
||||||
<label>نام و نام خانوادگی<input v-model="editName" required minlength="2" /></label>
|
<label>نام و نام خانوادگی<input v-model="editName" required minlength="2" /></label>
|
||||||
@@ -1875,22 +1966,53 @@ tbody tr + tr td {
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-time {
|
.logout-cell {
|
||||||
width: 7.2rem;
|
display: inline-flex;
|
||||||
min-height: 2rem;
|
align-items: center;
|
||||||
border: 1px solid #e2e8f0;
|
gap: 0.3rem;
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0 0.45rem;
|
|
||||||
font: inherit;
|
|
||||||
background: #fff;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-time:disabled {
|
.logout-btn,
|
||||||
background: #f8fafc;
|
.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;
|
color: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-btn.on {
|
||||||
|
border-color: #99f6e4;
|
||||||
|
background: #f0fdfa;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
.roles-grid,
|
.roles-grid,
|
||||||
.roles-tree {
|
.roles-tree {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -378,6 +378,8 @@ export interface OrgUserDto {
|
|||||||
priorArchiveEnabled?: boolean;
|
priorArchiveEnabled?: boolean;
|
||||||
/** ساعت خروج روزانه HH:mm به وقت تهران؛ خالی یعنی بدون محدودیت */
|
/** ساعت خروج روزانه HH:mm به وقت تهران؛ خالی یعنی بدون محدودیت */
|
||||||
logoutTime?: string | null;
|
logoutTime?: string | null;
|
||||||
|
/** بعد از ساعت خروج تا پایان روز نمیتواند دوباره وارد شود */
|
||||||
|
blockLoginAfterLogout?: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user