diff --git a/apps/api/prisma/migrations/20261021150000_membership_block_after_logout/migration.sql b/apps/api/prisma/migrations/20261021150000_membership_block_after_logout/migration.sql new file mode 100644 index 0000000..50f35db --- /dev/null +++ b/apps/api/prisma/migrations/20261021150000_membership_block_after_logout/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Membership" ADD COLUMN "blockLoginAfterLogout" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 4d7e0b6..682fa2b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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 diff --git a/apps/api/src/attendance/attendance.service.ts b/apps/api/src/attendance/attendance.service.ts index 85a9a2c..5a236f3 100644 --- a/apps/api/src/attendance/attendance.service.ts +++ b/apps/api/src/attendance/attendance.service.ts @@ -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, diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 6527d42..326a918 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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, diff --git a/apps/api/src/users/dto.ts b/apps/api/src/users/dto.ts index b5bb363..6f98177 100644 --- a/apps/api/src/users/dto.ts +++ b/apps/api/src/users/dto.ts @@ -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 { diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 5501040..39cff28 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -79,6 +79,7 @@ export class UsersController { user.role, id, dto.logoutTime ?? null, + dto.blockLoginAfterLogout, ); } diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 040e0ac..7d65969 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -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 { 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(), }; } diff --git a/apps/web/src/views/TeamSettingsView.vue b/apps/web/src/views/TeamSettingsView.vue index e02e8bd..783e1e7 100644 --- a/apps/web/src/views/TeamSettingsView.vue +++ b/apps/web/src/views/TeamSettingsView.vue @@ -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(() => { {{ formatLastLogin(user.lastLoginAt) }} — - + + + + { + +
+ +

+ در این ساعت (به وقت تهران) کاربر خودکار از سامانه خارج می‌شود. با تیک کنار ساعت می‌توانید + ورود دوباره تا پایان همان روز را هم ببندید. +

+
+ +
+
@@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ebbadbc..16f22d3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -378,6 +378,8 @@ export interface OrgUserDto { priorArchiveEnabled?: boolean; /** ساعت خروج روزانه HH:mm به وقت تهران؛ خالی یعنی بدون محدودیت */ logoutTime?: string | null; + /** بعد از ساعت خروج تا پایان روز نمی‌تواند دوباره وارد شود */ + blockLoginAfterLogout?: boolean; createdAt: string; }