diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index 06c4862..8a928ae 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -1,10 +1,10 @@ Project Root: customerTicket -Generated At: 2026-10-13T12:06:20+00:00 -Python Files: 327 +Generated At: 2026-10-14T08:18:05+00:00 +Python Files: 333 Django Files: 49 JavaScript/TypeScript Files: 41 HTML Files: 77 -Total Files: 1582 +Total Files: 1594 Total Directories: 144 customerTicket @@ -214,6 +214,7 @@ customerTicket │ │ │ ├── 0010_organizationalunit_notification_destinations.py │ │ │ ├── 0011_organizationalunit_supervisor_mobile_4.py │ │ │ ├── 0012_organizationalunit_supervisor_mobile_5.py +│ │ │ ├── 0013_organizationalunit_supervisor_sms_ticket_link_enabled.py │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── admin.py @@ -240,6 +241,7 @@ customerTicket │ │ │ ├── 0016_scope_legacy_public_tickets_to_units.py │ │ │ ├── 0017_ticketaccessgrant_first_viewed_at_and_more.py │ │ │ ├── 0018_dispatch_response_opening.py +│ │ │ ├── 0019_staffticketshortlink.py │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── admin.py @@ -257,6 +259,7 @@ customerTicket │ │ ├── services.py │ │ ├── sharing.py │ │ ├── sharing_views.py +│ │ ├── short_links.py │ │ ├── staff_conversation.py │ │ ├── staff_urls.py │ │ └── views.py @@ -331,6 +334,7 @@ customerTicket │ ├── PRODUCTION_SEED.md │ ├── QA_PERMISSION_MATRIX.md │ ├── STAFF_SHARING.md +│ ├── STAFF_TICKET_SHORT_LINKS.md │ └── THEMING.md ├── graphify-out │ ├── 2026-09-05 @@ -479,6 +483,7 @@ customerTicket │ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json │ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json │ │ │ ├── 143ae363276b4a1db64805529e618db71c204aa56bc8b54761693a9a2060336c.json +│ │ │ ├── 1468b7e152af37dfc56ad4b7c02e23deeb6880efba63a89dcb81934bb30054bd.json │ │ │ ├── 14ffc999200b172d3919e3d9899656c1f8a212769b1e86fe388b82fa302ed1be.json │ │ │ ├── 15539048155c760644fc70dd1fbff3855ceedf11bc6ef7b270d1c7280cb4df48.json │ │ │ ├── 175f840f1587093869a030b52c3b019e020c2fb7cf1223579b251b4857752130.json @@ -687,6 +692,7 @@ customerTicket │ │ │ ├── 7bc90a6d89ccfaea18fd0aa05f2ab506d0639360f33413bfa9385ddf49e6d2fe.json │ │ │ ├── 7cb69c820b549230484188086c76dd8d20f68f682fa6345c879cb80d54e09849.json │ │ │ ├── 7cd278b7ea82af8777e4da2f84fd1773dae3dd371744f66bbe12c0b925fd6486.json +│ │ │ ├── 7d1800caddb416b3c12e4c593d0240bd52111e04f5da48a44cf879ee42aa3603.json │ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json │ │ │ ├── 7de85cc2c6e58ead611e3ac76417ecd5316b7fd3f82ae70937c829eb809ea840.json │ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json @@ -882,6 +888,7 @@ customerTicket │ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json │ │ │ ├── dea57fa4a89ef99a815bd9b1bb71b8475727a3d7d88161a9a696ebf636f29ed0.json │ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json +│ │ │ ├── dfa1b188974aa98a6cf45345da9e0a8071b7de5b8a6392494482d6733b19c56e.json │ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json │ │ │ ├── e00e2e63606d8a49d1669a78789e6be1dc4d2aa78365ce58439e1d7c7975e2bf.json │ │ │ ├── e247b821b3662d49d7381b743644858c0d14fd5b7bed224964c292f7ce9daad3.json @@ -1126,7 +1133,8 @@ customerTicket │ │ ├── query_20260917_063554_1824eb56_پس_از_باز_شدن_کیبورد_موبایل__کادر_نوشتن_پاسخ_تیکت.md │ │ ├── query_20260923_131543_e5730a0b_how_do_staff_ticket_sms_status__failed_delivery__a.md │ │ ├── query_20261006_111656_eab3ac96_how_do_payment_approval_policy_and_receipt_approva.md -│ │ └── query_20261007_080904_8f4d683c_payment_notice__bank_account_snapshots__short_paym.md +│ │ ├── query_20261007_080904_8f4d683c_payment_notice__bank_account_snapshots__short_paym.md +│ │ └── query_20261013_122853_ce932abc_how_do_organizational_ticketing_rules_relate_to_su.md │ ├── reflections │ │ └── LESSONS.md │ ├── .graphify_analysis.json @@ -1173,6 +1181,7 @@ customerTicket │ ├── db-before-payment-links-20261006.sqlite3 │ ├── db-before-receipt-customer-details-20261011.sqlite3 │ ├── db-before-staff-sharing.sqlite3 +│ ├── db-before-staff-ticket-short-links-20261013.sqlite3 │ ├── db-pre-chaharsu.sqlite3 │ ├── deposit-sharing-live-1440.png │ ├── deposit-sharing-live-320.png @@ -1648,6 +1657,7 @@ customerTicket │ │ │ ├── receipt_upload.py │ │ │ ├── sharing.py │ │ │ ├── staff_payments.py +│ │ │ ├── staff_short_link.py │ │ │ ├── ticket_notices.py │ │ │ └── tickets.py │ │ ├── config.py @@ -1676,6 +1686,7 @@ customerTicket │ │ ├── test_staff_operations_ui.py │ │ ├── test_staff_payment_sms_e2e.py │ │ ├── test_staff_sharing_e2e.py +│ │ ├── test_staff_ticket_short_links_e2e.py │ │ └── test_ticket_notices_e2e.py │ ├── js │ │ └── chat-coach.test.mjs @@ -1709,6 +1720,7 @@ customerTicket │ ├── test_staff_payment_sms.py │ ├── test_staff_shared_queue.py │ ├── test_staff_sharing.py +│ ├── test_staff_ticket_short_links.py │ ├── test_views.py │ └── test_voip_calls.py ├── .dockerignore diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py index 97afb4c..fa4dc0c 100644 --- a/apps/accounts/apps.py +++ b/apps/accounts/apps.py @@ -2,3 +2,6 @@ from django.apps import AppConfig class AccountsConfig(AppConfig): name = "apps.accounts" + def ready(self): + from . import customer_access # Register the login-session receiver. + diff --git a/apps/accounts/customer_access.py b/apps/accounts/customer_access.py new file mode 100644 index 0000000..4e7c56a --- /dev/null +++ b/apps/accounts/customer_access.py @@ -0,0 +1,81 @@ +"""Restrictions for full customer logins; staff and scoped links stay independent.""" + +from django.contrib import messages +from django.contrib.auth import logout +from django.contrib.auth.signals import user_logged_in +from django.dispatch import receiver +from django.http import JsonResponse +from django.shortcuts import redirect +from django.urls import reverse +from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_GET +from django.utils.cache import add_never_cache_headers + +from apps.customers.models import CustomerProfile +from .models import User + + +ACCESS_VERSION_KEY = "customer_login_access_version" +BLOCK_REASON_KEY = "customer_login_block_reason" +DEFAULT_BLOCK_REASON = "ورود شما مسدود شده است. برای پیگیری با پشتیبانی تماس بگیرید." + + +def is_customer_login(user): + return user.account_type == User.AccountType.CUSTOMER and not (user.is_staff or user.is_superuser) + + +def customer_login_profile(user): + if not is_customer_login(user): + return None + return CustomerProfile.objects.filter(user_id=user.pk).first() + + +def blocked_login_reason(mobile): + user = User.objects.filter(mobile=mobile).first() + profile = customer_login_profile(user) if user else None + if profile and profile.login_blocked: + return profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON + return "" + + +@receiver(user_logged_in, dispatch_uid="customer_login_access_version") +def remember_customer_access(sender, request, user, **kwargs): + if not is_customer_login(user): + return + profile = customer_login_profile(user) + request.session[ACCESS_VERSION_KEY] = profile.login_access_version if profile else 0 + request.session.pop(BLOCK_REASON_KEY, None) + + +class CustomerLoginAccessMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.user.is_authenticated: + profile = customer_login_profile(request.user) + if profile and ( + profile.login_blocked + or request.session.get(ACCESS_VERSION_KEY, 0) != profile.login_access_version + ): + logout(request) + if profile.login_blocked: + request.session[BLOCK_REASON_KEY] = profile.login_block_reason.strip() or DEFAULT_BLOCK_REASON + else: + messages.error(request, "نشست قبلی شما باطل شده است. لطفاً دوباره وارد شوید.") + if request.path == reverse("accounts:login_access"): + response = JsonResponse({"authenticated": False, "login_url": reverse("accounts:login")}) + else: + response = redirect("accounts:login") + add_never_cache_headers(response) + return response + return self.get_response(request) + + +@never_cache +@require_GET +def customer_login_access(request): + return JsonResponse({ + "authenticated": request.user.is_authenticated, + "login_url": reverse("accounts:login"), + }) diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py index 8c251b2..570489a 100644 --- a/apps/accounts/urls.py +++ b/apps/accounts/urls.py @@ -1,9 +1,11 @@ from django.urls import path from . import views +from .customer_access import customer_login_access app_name = "accounts" urlpatterns = [ + path("login/access/", customer_login_access, name="login_access"), path("", views.home, name="home"), path("login/", views.customer_login, name="login"), path("login/password/", views.customer_password_login, name="password_login"), diff --git a/apps/accounts/views.py b/apps/accounts/views.py index 79a7541..815c8fd 100644 --- a/apps/accounts/views.py +++ b/apps/accounts/views.py @@ -14,6 +14,7 @@ from django.db import transaction from django.db.models import F from .models import MagicLinkAccessLog, OTPChallenge, TicketMagicLink, User from .magic_access import grant_ticket_magic_access +from .customer_access import BLOCK_REASON_KEY, blocked_login_reason from apps.integrations.sms.providers import SMSResult, get_sms_provider import logging @@ -46,7 +47,11 @@ def customer_login(request): _remember_destination(request) if response:=_authenticated_home(request): return response form = MobileForm(request.POST or None) + block_reason = request.session.pop(BLOCK_REASON_KEY, "") if request.method == "POST" and form.is_valid(): + block_reason = blocked_login_reason(form.cleaned_data["mobile"]) + if block_reason: + return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason}) challenge = None try: challenge, code = OTPChallenge.issue(form.cleaned_data["mobile"], request) @@ -62,13 +67,18 @@ def customer_login(request): challenge.delete() logger.exception("OTP SMS delivery failed") form.add_error(None, "ارسال پیامک ورود در حال حاضر ممکن نیست؛ لطفاً کمی بعد دوباره تلاش کنید.") - return render(request, "accounts/customer_login.html", {"form": form}) + return render(request, "accounts/customer_login.html", {"form": form, "block_reason": block_reason}) @require_http_methods(["GET", "POST"]) def verify_otp(request): if response:=_authenticated_home(request): return response challenge = get_object_or_404(OTPChallenge, pk=request.session.get("otp_challenge_id")) form = OTPForm(request.POST or None) + block_reason = blocked_login_reason(challenge.mobile) + if block_reason: + request.session.pop("otp_challenge_id", None) + request.session[BLOCK_REASON_KEY] = block_reason + return redirect("accounts:login") if request.method == "POST" and form.is_valid(): if challenge.verify(form.cleaned_data["code"]): user, created = User.objects.get_or_create(mobile=challenge.mobile, defaults={"username": f"customer_{challenge.mobile[3:]}", "account_type": User.AccountType.CUSTOMER}) @@ -86,12 +96,16 @@ def customer_password_login(request): _remember_destination(request) if response:=_authenticated_home(request): return response form=CustomerPasswordLoginForm(request.POST or None) + block_reason = request.session.pop(BLOCK_REASON_KEY, "") if request.method=="POST" and form.is_valid(): + block_reason = blocked_login_reason(form.cleaned_data["mobile"]) + if block_reason: + return render(request, "accounts/customer_password_login.html", {"form": form, "block_reason": block_reason}) user=form.authenticate(request) if user and user.is_active: login(request,user); return redirect(_login_destination(request)) form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.") - return render(request,"accounts/customer_password_login.html",{"form":form}) + return render(request,"accounts/customer_password_login.html",{"form":form,"block_reason":block_reason}) @login_required @require_http_methods(["GET","POST"]) diff --git a/apps/customers/admin.py b/apps/customers/admin.py index a1bc0f2..582923b 100644 --- a/apps/customers/admin.py +++ b/apps/customers/admin.py @@ -11,8 +11,8 @@ class ContactGroupAdmin(admin.ModelAdmin): @admin.register(CustomerProfile) class CustomerProfileAdmin(admin.ModelAdmin): - list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name") - list_filter = ("stakeholder_type", "groups") + list_display = ("user", "stakeholder_type", "organization_name", "first_name", "last_name", "login_blocked") + list_filter = ("stakeholder_type", "groups", "login_blocked") search_fields = ("user__mobile", "organization_name", "first_name", "last_name") filter_horizontal = ("groups",) diff --git a/apps/customers/migrations/0007_customerprofile_login_access_version_and_more.py b/apps/customers/migrations/0007_customerprofile_login_access_version_and_more.py new file mode 100644 index 0000000..5f37ef0 --- /dev/null +++ b/apps/customers/migrations/0007_customerprofile_login_access_version_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.1 on 2026-10-14 08:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0006_backfill_customer_profiles'), + ] + + operations = [ + migrations.AddField( + model_name='customerprofile', + name='login_access_version', + field=models.PositiveIntegerField(default=0, editable=False), + ), + migrations.AddField( + model_name='customerprofile', + name='login_block_reason', + field=models.TextField(blank=True, help_text='این متن به مشتری یا مخاطب نمایش داده می\u200cشود. ورود کارکنان و کارشناسان تحت تأثیر نیست.', verbose_name='دلیل مسدودی ورود'), + ), + migrations.AddField( + model_name='customerprofile', + name='login_blocked', + field=models.BooleanField(default=False, verbose_name='مسدود کردن ورود مشتری / مخاطب'), + ), + ] diff --git a/apps/customers/models.py b/apps/customers/models.py index 7be85b0..6f386a2 100644 --- a/apps/customers/models.py +++ b/apps/customers/models.py @@ -1,5 +1,7 @@ from django.conf import settings from django.db import models +from django.db import transaction +from django.core.exceptions import ValidationError from apps.core.models import PersianTextNormalizationMixin, TimeStampedModel @@ -51,6 +53,34 @@ class CustomerProfile(PersianTextNormalizationMixin, TimeStampedModel): groups = models.ManyToManyField(ContactGroup, blank=True, related_name="contacts") province = models.CharField(max_length=100, blank=True) city = models.CharField(max_length=100, blank=True) + login_blocked = models.BooleanField("مسدود کردن ورود مشتری / مخاطب", default=False) + login_block_reason = models.TextField( + "دلیل مسدودی ورود", blank=True, + help_text="این متن به مشتری یا مخاطب نمایش داده می‌شود. ورود کارکنان و کارشناسان تحت تأثیر نیست.", + ) + login_access_version = models.PositiveIntegerField(default=0, editable=False) + + def clean(self): + super().clean() + if self.login_blocked and not self.login_block_reason.strip(): + raise ValidationError({"login_block_reason": "برای مسدود کردن ورود، دلیل مسدودی را بنویسید."}) + + def save(self, *args, **kwargs): + # Keep old sessions revoked even if the administrator later unblocks access. + with transaction.atomic(): + previous = type(self).objects.select_for_update().filter(pk=self.pk).first() + if previous is None and self.login_blocked: + self.login_access_version = 1 + if previous is not None: + self.login_access_version = previous.login_access_version + fields = kwargs.get("update_fields") + changes_access = fields is None or "login_blocked" in fields + if changes_access and self.login_blocked and not previous.login_blocked: + self.login_access_version += 1 + if fields is not None: + kwargs["update_fields"] = set(fields) | {"login_access_version"} + super().save(*args, **kwargs) + def __str__(self): return (f"{self.first_name} {self.last_name}".strip() or self.user.mobile or self.user.username) diff --git a/config/settings/base.py b/config/settings/base.py index 3c7e437..4a6e314 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -29,7 +29,8 @@ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.messages.middleware.MessageMiddleware", "apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", "apps.accounts.customer_access.CustomerLoginAccessMiddleware", + "apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "config.urls" diff --git a/docs/CUSTOMER_LOGIN_ACCESS.md b/docs/CUSTOMER_LOGIN_ACCESS.md new file mode 100644 index 0000000..df8b86c --- /dev/null +++ b/docs/CUSTOMER_LOGIN_ACCESS.md @@ -0,0 +1,57 @@ +# مسدودی ورود مشتریان و مخاطبان + +در Django Admin، پروفایل مشتری / مخاطب را با شماره موبایل جست‌وجو کنید. +گزینهٔ «مسدود کردن ورود مشتری / مخاطب» را فعال کنید، «دلیل مسدودی ورود» را +بنویسید و ذخیره کنید. ثبت مسدودی بدون دلیل در ادمین پذیرفته نمی‌شود. +برای رفع مسدودی، همان گزینه را غیرفعال کنید و ذخیره کنید. + +این تنظیم ورود با موبایل و OTP در `/login/` و ورود با رمز عبور در +`/login/password/` را کنترل می‌کند. دلیل با متن قرمز، فونت برنامه و کادر +دارای `role="alert"` نمایش داده می‌شود. متن دلیل HTML اجرا نمی‌کند. +تنظیمات مسدودی در فرم مشتری و فرم مدیریت مخاطبان کارشناسان قابل تغییر نیست. + +هر بار مسدود کردن، نسخهٔ دسترسی پروفایل را افزایش می‌دهد. نشست‌های ورود +قدیمی در نخستین درخواست بعدی خارج می‌شوند و حتی پس از رفع مسدودی قابل +استفاده نیستند؛ ورود مجدد لازم است. نشست‌های قبل از استقرار این قابلیت +نسخهٔ صفر دارند و تا زمانی که حساب مسدود نشود معتبر می‌مانند. + +صفحات مشتری هر ۱۰ ثانیه وضعیت نشست را از `/login/access/` بررسی می‌کنند. +در مرورگر باز و متصل، مسدودی بدون کلیک کاربر نیز باعث انتقال به صفحهٔ +ورود و نمایش دلیل می‌شود. هنگام بازگشت به تب، باز شدن مجدد صفحه یا اتصال +دوبارهٔ شبکه هم بررسی انجام می‌شود. کنترل سمت سرور به جاوااسکریپت وابسته نیست. + +حساب‌های `STAFF`، کاربران دارای `is_staff` و ابرکاربران از این محدودیت +مستثنا هستند. لینک‌های امن با دسترسی محدود به تیکت یا پرداخت، نشست ورود +کامل مشتری ایجاد نمی‌کنند و قواعد مستقل قبلی خود را حفظ می‌کنند. +این قابلیت مقدار `is_active` حساب را تغییر نمی‌دهد. + +Migration مورد نیاز: `customers.0007_customerprofile_login_access_version_and_more`. +برای استقرار با پایتون محیط پروژه، `manage.py migrate` را اجرا کنید و سرویس +Django را دوباره راه‌اندازی کنید. + +تست‌ها در `tests/test_customer_login_access.py` و +`tests/e2e/test_customer_login_access_e2e.py` قرار دارند. تست مرورگر از +`live_server` و Chromium مدیریت‌شدهٔ پروژه استفاده می‌کند. + +## نتیجهٔ بررسی + +- مجموعهٔ گستردهٔ ورود، نقش‌ها، مخاطبان، پرداخت‌ها، لینک‌های امن و انتقال + داده: ۲۶۰ تست موفق. +- آخرین اجرای پس از اصلاح ترتیب middlewareها: ۸۵ تست موفق، شامل + `test_customer_login_access.py`، `test_views.py`، `test_role_workflow_regression.py`، + تست‌های مرورگر مسدودی و `test_pwa_versioning.py`. +- کنترل‌های Django، نبود migration جاافتاده، بررسی نحو Python و JavaScript + و `git diff --check` برای فایل‌های این قابلیت موفق بودند. +- صفحهٔ ورود، CSS قرمز نسخه‌دار و endpoint بدون کش نشست از سرور توسعه + روی `0.0.0.0:8000` با پاسخ HTTP 200 بررسی شدند. + +Graphify برای ارتباط مفهومی پروفایل و دسترسی‌ها و Ripwire برای بررسی +نمادها، اثر تغییر، محل استفاده و گیت‌های پس از تغییر اجرا شدند. +`test-gate` فهرست تست‌های لازم را ارائه می‌کند و خود تست‌ها را اجرا نمی‌کند؛ +تست‌های معرفی‌شده جداگانه اجرا و موفق شدند. `quality-delta` سبز نشد: +هشدارهایی دربارهٔ callbackهای Django، تست‌های کشف‌شده توسط pytest و شباهت +ساختاری توابع بازیابی رکورد باقی مانده است. برای نمونه، بازیابی +`CustomerProfile` با بازیابی `DispatchResponseControl` شبیه تشخیص داده شد؛ +این دو به مدل‌ها و قواعد مستقل مربوط‌اند و ادغام آن‌ها مناسب نیست. +این هشدارهای تحلیل ایستا به معنی شکست تست‌های اجرایی نیستند و به‌عنوان +نتیجهٔ سبز گیت کیفیت گزارش نشده‌اند. diff --git a/static/css/customer-login-access.css b/static/css/customer-login-access.css new file mode 100644 index 0000000..0a1a8f0 --- /dev/null +++ b/static/css/customer-login-access.css @@ -0,0 +1,17 @@ +.customer-login-block { + margin-block: 1.25rem; + padding: 1rem 1.25rem; + border: 1px solid #fca5a5; + border-inline-start: 4px solid #b91c1c; + border-radius: 12px; + background: #fff1f2; + color: #991b1b; + font-family: inherit; + font-size: 1rem; + line-height: 1.9; + text-align: start; + overflow-wrap: anywhere; +} +.customer-login-block strong { display: block; font-size: 1.125rem; font-weight: 800; } +.customer-login-block .customer-login-block-label { margin: .5rem 0 0; color: #991b1b; } +.customer-login-block .customer-login-block-reason { margin: .25rem 0 0; color: #991b1b; font-weight: 700; white-space: pre-wrap; } diff --git a/static/js/customer-login-access.js b/static/js/customer-login-access.js new file mode 100644 index 0000000..00aa990 --- /dev/null +++ b/static/js/customer-login-access.js @@ -0,0 +1,25 @@ +(() => { + const script = document.querySelector('script[data-customer-login-access]'); + if (!script) return; + let pending = false; + async function checkAccess() { + if (pending || document.hidden) return; + pending = true; + try { + const response = await fetch(script.dataset.customerLoginAccess, { + credentials: 'same-origin', cache: 'no-store', headers: {Accept: 'application/json'} + }); + if (!response.ok) return; + const result = await response.json(); + if (!result.authenticated) window.location.replace(result.login_url); + } catch (_) { + // Retry on the next heartbeat or when the browser returns online. + } finally { + pending = false; + } + } + window.setInterval(checkAccess, 10000); + document.addEventListener('visibilitychange', checkAccess); + window.addEventListener('pageshow', checkAccess); + window.addEventListener('online', checkAccess); +})(); diff --git a/templates/accounts/customer_login.html b/templates/accounts/customer_login.html index 323d50f..be295b8 100644 --- a/templates/accounts/customer_login.html +++ b/templates/accounts/customer_login.html @@ -4,7 +4,7 @@ {% block content %}
{% if site_branding.login_intro_enabled %}
{{ site_branding.login_intro_icon }}

{{ site_branding.login_intro_eyebrow }}

{{ site_branding.login_intro_heading|linebreaksbr }}

{{ site_branding.login_intro_body|linebreaksbr }}

{% endif %} -
{% include 'includes/auth_brand.html' with hide_copy=True %}

ورود مشتریان

خوش آمدید

شماره موبایل خود را وارد کنید؛ نیازی به ثبت‌نام یا رمز عبور نیست.

{% csrf_token %}{{ form.as_p }}
{% include 'includes/auth_footer.html' %}
+
{% include 'includes/auth_brand.html' with hide_copy=True %}

ورود مشتریان

خوش آمدید

شماره موبایل خود را وارد کنید؛ نیازی به ثبت‌نام یا رمز عبور نیست.

{% include 'includes/customer_login_block.html' %}
{% csrf_token %}{{ form.as_p }}
{% include 'includes/auth_footer.html' %}
{% endblock %} {% block footer %}{% endblock %} diff --git a/templates/accounts/customer_password_login.html b/templates/accounts/customer_password_login.html index db8965d..785f56c 100644 --- a/templates/accounts/customer_password_login.html +++ b/templates/accounts/customer_password_login.html @@ -4,7 +4,7 @@ {% block content %}
●

ورود سریع

دوباره خوش آمدید.

اگر قبلاً در پروفایل خود رمز تعیین کرده‌اید، بدون انتظار برای پیامک وارد شوید.

-
{% include 'includes/auth_brand.html' %}

حساب مشتری

ورود با رمز عبور

شماره موبایل و رمز انتخابی خود را وارد کنید.

{% csrf_token %}{{ form.as_p }}
{% include 'includes/auth_footer.html' %}
+
{% include 'includes/auth_brand.html' %}

حساب مشتری

ورود با رمز عبور

شماره موبایل و رمز انتخابی خود را وارد کنید.

{% include 'includes/customer_login_block.html' %}
{% csrf_token %}{{ form.as_p }}
{% include 'includes/auth_footer.html' %}
{% endblock %} {% block footer %}{% endblock %} diff --git a/templates/base.html b/templates/base.html index 8b15f7f..fdc3bde 100644 --- a/templates/base.html +++ b/templates/base.html @@ -12,6 +12,7 @@ + {% if request.resolver_match.namespace == 'accounts' %}{% endif %} {% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' or financial_page or request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name == 'list' or request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name == 'new' %}{% endif %} {% block extra_head %}{% endblock %} @@ -73,6 +74,7 @@ {% if user.is_authenticated %}{% endif %} {% if user.is_authenticated and user.account_type == 'STAFF' %}{% endif %} {% block extra_scripts %}{% endblock %} +{% if user.is_authenticated and user.account_type == 'CUSTOMER' and not user.is_staff and not user.is_superuser %}{% endif %} {% block otp_script %}{% endblock %} {% endblock %} diff --git a/templates/includes/customer_login_block.html b/templates/includes/customer_login_block.html new file mode 100644 index 0000000..ed3fda1 --- /dev/null +++ b/templates/includes/customer_login_block.html @@ -0,0 +1,7 @@ +{% if block_reason %} + +{% endif %} diff --git a/tests/e2e/pages/customer_login_access.py b/tests/e2e/pages/customer_login_access.py new file mode 100644 index 0000000..3aa829f --- /dev/null +++ b/tests/e2e/pages/customer_login_access.py @@ -0,0 +1,25 @@ +from django.urls import reverse +from playwright.sync_api import expect + +from tests.e2e.pages.auth import AuthenticationPage + + +class CustomerLoginAccessPage(AuthenticationPage): + def request_otp(self, mobile): + self.goto(reverse("accounts:login")) + self.page.get_by_label("شماره موبایل").fill(mobile) + self.page.get_by_role("button", name="دریافت کد یک‌بارمصرف").click() + + def assert_block_reason(self, reason): + alert = self.page.get_by_role("alert") + expect(alert).to_contain_text("ورود به حساب شما مسدود است") + expect(alert).to_contain_text(reason) + expect(alert).to_be_visible() + assert alert.locator(".customer-login-block-reason").evaluate( + "node => getComputedStyle(node).color" + ) == "rgb(153, 27, 27)" + assert self.page.evaluate("document.documentElement.scrollWidth <= innerWidth + 1") + + def assert_logged_out(self, reason): + expect(self.page).to_have_url(self.url(reverse("accounts:login")), timeout=20000) + self.assert_block_reason(reason) diff --git a/tests/e2e/test_customer_login_access_e2e.py b/tests/e2e/test_customer_login_access_e2e.py new file mode 100644 index 0000000..5a5acef --- /dev/null +++ b/tests/e2e/test_customer_login_access_e2e.py @@ -0,0 +1,44 @@ +import pytest +from concurrent.futures import ThreadPoolExecutor +from django.db import connections + +from apps.accounts.models import User +from apps.customers.models import CustomerProfile +from tests.e2e.pages.customer_login_access import CustomerLoginAccessPage + + +pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)] +REASON = "به علت بدهی معوق، ورود شما تا بررسی واحد مالی مسدود شده است." +PASSWORD = "Customer-access-e2e-2026!" + + +def block_customer(profile_id): + try: + profile = CustomerProfile.objects.get(pk=profile_id) + profile.login_blocked = True + profile.login_block_reason = REASON + profile.save(update_fields=["login_blocked", "login_block_reason"]) + finally: + connections.close_all() + + +@pytest.mark.parametrize("width", [320, 1280]) +def test_block_reason_is_clear_and_red_on_mobile_and_desktop(live_server, browser_page, width): + user = User.objects.create_user(username="blocked-ui", mobile="09121234566", password=PASSWORD) + CustomerProfile.objects.create(user=user, login_blocked=True, login_block_reason=REASON) + with browser_page() as page: + page.set_viewport_size({"width": width, "height": 850}) + access = CustomerLoginAccessPage(page, live_server.url) + access.request_otp(user.local_mobile) + access.assert_block_reason(REASON) + + +def test_open_customer_browser_logs_out_without_interaction(live_server, browser_page): + user = User.objects.create_user(username="idle-ui", mobile="09121234566", password=PASSWORD) + profile = CustomerProfile.objects.create(user=user) + with browser_page() as page: + access = CustomerLoginAccessPage(page, live_server.url) + access.login_customer(user, PASSWORD) + with ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(block_customer, profile.pk).result() + access.assert_logged_out(REASON) diff --git a/tests/test_customer_login_access.py b/tests/test_customer_login_access.py new file mode 100644 index 0000000..f87d1d3 --- /dev/null +++ b/tests/test_customer_login_access.py @@ -0,0 +1,215 @@ +from unittest.mock import patch + +import pytest +from django.contrib.auth import SESSION_KEY +from django.test import Client +from django.urls import reverse + +from apps.accounts.models import OTPChallenge, User +from apps.customers.models import CustomerProfile +from apps.staff.models import StaffProfile + + +pytestmark = pytest.mark.django_db +PASSWORD = "Customer-access-2026!" +REASON = "به علت بدهی معوق، ورود شما تا بررسی واحد مالی مسدود شده است." + + +@pytest.fixture +def customer(): + user = User.objects.create_user(username="blocked-customer", mobile="09121234566", password=PASSWORD) + CustomerProfile.objects.create(user=user) + return user + + +def block(user, reason=REASON): + profile = CustomerProfile.objects.get(user=user) + profile.login_blocked = True + profile.login_block_reason = reason + profile.save(update_fields=["login_blocked", "login_block_reason"]) + return profile + + +@pytest.mark.parametrize("stakeholder", CustomerProfile.StakeholderType.values) +def test_blocked_customers_and_contacts_cannot_request_sms(client, customer, stakeholder): + profile = block(customer) + profile.stakeholder_type = stakeholder + profile.save(update_fields=["stakeholder_type"]) + with patch("apps.accounts.views.get_sms_provider") as provider: + response = client.post(reverse("accounts:login"), {"mobile": customer.local_mobile}) + assert response.status_code == 200 + assert REASON in response.content.decode() + assert 'role="alert"' in response.content.decode() + assert SESSION_KEY not in client.session + assert not OTPChallenge.objects.exists() + provider.assert_not_called() + + +def test_password_login_is_blocked_and_reason_is_escaped(client, customer): + block(customer, "علت مسدودی\n") + response = client.post(reverse("accounts:password_login"), {"mobile": customer.local_mobile, "password": PASSWORD}) + content = response.content.decode() + assert response.status_code == 200 + assert "علت مسدودی" in content + assert "<script>" in content + assert "" not in content + assert SESSION_KEY not in client.session + + +@pytest.mark.parametrize("method", ["get", "post"]) +def test_blocking_after_otp_issue_prevents_verification(client, customer, method): + challenge, code = OTPChallenge.issue(customer.mobile) + session = client.session + session["otp_challenge_id"] = challenge.pk + session.save() + block(customer) + response = getattr(client, method)(reverse("accounts:otp"), {"code": code}, follow=True) + assert response.status_code == 200 + assert REASON in response.content.decode() + assert SESSION_KEY not in client.session + assert "otp_challenge_id" not in client.session + + +@pytest.mark.parametrize("endpoint", ["accounts:profile", "tickets:list", "payments:home", "accounts:login", "accounts:login_access"]) +def test_blocking_logs_out_all_existing_sessions(client, customer, endpoint): + other_browser = Client() + client.force_login(customer) + other_browser.force_login(customer) + block(customer) + for browser in (client, other_browser): + response = browser.get(reverse(endpoint)) + if endpoint == "accounts:login_access": + assert response.json()["authenticated"] is False + assert "no-store" in response["Cache-Control"] + else: + assert response.status_code == 302 + assert response.url == reverse("accounts:login") + assert SESSION_KEY not in browser.session + assert REASON in browser.get(reverse("accounts:login")).content.decode() + + +def test_blocked_session_cannot_write_customer_profile(client, customer): + client.force_login(customer) + block(customer) + response = client.post(reverse("accounts:profile"), {"first_name": "تغییر غیرمجاز"}) + assert response.status_code == 302 + customer.customer_profile.refresh_from_db() + assert customer.customer_profile.first_name == "" + + +def test_unblocking_does_not_restore_an_old_session_but_allows_new_login(client, customer): + client.force_login(customer) + profile = block(customer) + profile.login_blocked = False + profile.save(update_fields=["login_blocked"]) + response = client.get(reverse("accounts:profile")) + assert response.url == reverse("accounts:login") + assert SESSION_KEY not in client.session + login_page = client.get(response.url).content.decode() + assert "نشست قبلی شما باطل شده است." in login_page + assert "ورود به حساب شما مسدود است" not in login_page + response = client.post(reverse("accounts:password_login"), {"mobile": customer.local_mobile, "password": PASSWORD}) + assert response.url == reverse("payments:home") + assert SESSION_KEY in client.session + assert client.get(reverse("accounts:profile")).status_code == 200 + + +def test_unblocked_customer_can_still_login_with_otp(client, customer): + challenge, code = OTPChallenge.issue(customer.mobile) + session = client.session + session["otp_challenge_id"] = challenge.pk + session.save() + response = client.post(reverse("accounts:otp"), {"code": code}) + assert response.url == reverse("payments:home") + assert SESSION_KEY in client.session + + +@pytest.mark.parametrize("role", ["staff", "django_staff", "superuser"]) +def test_staff_and_admin_sessions_are_exempt_even_with_blocked_customer_profile(client, role): + user = User.objects.create_user( + username=role, password=PASSWORD, mobile="09129998877", + account_type="STAFF" if role == "staff" else "CUSTOMER", + is_staff=role == "django_staff", is_superuser=role == "superuser", + ) + CustomerProfile.objects.create(user=user, login_blocked=True, login_block_reason=REASON) + if role == "staff": + StaffProfile.objects.create(user=user, display_name="کارشناس") + response = client.post(reverse("accounts:staff_login"), {"username": user.username, "password": PASSWORD}) + assert response.url == reverse("staff_tickets:list") + workspace = client.get(reverse("staff_tickets:list")) + assert workspace.status_code == 200 + assert "js/customer-login-access.js" not in workspace.content.decode() + else: + client.force_login(user) + assert client.get(reverse("accounts:login_access")).json()["authenticated"] is True + assert SESSION_KEY in client.session + response = client.get(reverse("accounts:login")) + assert response.status_code == 302 + assert SESSION_KEY in client.session + + +def test_only_admin_can_change_login_block_settings(client, customer): + client.force_login(customer) + client.post(reverse("accounts:profile"), { + "first_name": "مشتری", "last_name": "آزمایشی", "customer_type": "PERSON", + "login_blocked": "on", "login_block_reason": REASON, + }) + profile = CustomerProfile.objects.get(user=customer) + assert not profile.login_blocked + assert profile.login_block_reason == "" + + +def test_admin_requires_a_reason_and_can_block_and_unblock(client, customer): + admin = User.objects.create_superuser(username="access-admin", password=PASSWORD) + client.force_login(admin) + profile = customer.customer_profile + url = reverse("admin:customers_customerprofile_change", args=[profile.pk]) + data = {"user": customer.pk, "stakeholder_type": "CUSTOMER", "login_blocked": "on", "login_block_reason": " "} + response = client.post(url, data) + assert response.status_code == 200 + assert "برای مسدود کردن ورود، دلیل مسدودی را بنویسید." in response.content.decode() + profile.refresh_from_db() + assert not profile.login_blocked + data["login_block_reason"] = REASON + assert client.post(url, data).status_code == 302 + profile.refresh_from_db() + assert profile.login_blocked and profile.login_block_reason == REASON + assert profile.login_access_version == 1 + data.pop("login_blocked") + assert client.post(url, data).status_code == 302 + profile.refresh_from_db() + assert not profile.login_blocked + assert profile.login_access_version == 1 + + +def test_legacy_session_without_access_version_is_revoked(client, customer): + client.force_login(customer) + session = client.session + session.pop("customer_login_access_version") + session.save() + block(customer) + assert client.get(reverse("accounts:profile")).url == reverse("accounts:login") + assert SESSION_KEY not in client.session + + +def test_missing_profile_does_not_prevent_customer_login(client): + user = User.objects.create_user(username="no-profile", mobile="09120000111", password=PASSWORD) + response = client.post(reverse("accounts:password_login"), {"mobile": user.local_mobile, "password": PASSWORD}) + assert response.url == reverse("payments:home") + assert client.get(reverse("accounts:login_access")).json()["authenticated"] is True + + +def test_login_block_does_not_change_scoped_magic_link_access(client, customer): + from apps.accounts.models import TicketMagicLink + from apps.tickets.models import TicketCategory + from apps.tickets.services import create_ticket + + category = TicketCategory.objects.create(title="فروش", slug="login-access-sale") + ticket = create_ticket(customer=customer, category=category, subject="دسترسی لینک", body="متن") + token = TicketMagicLink.issue(ticket) + block(customer) + response = client.get(reverse("accounts:magic", args=[token]), follow=True) + assert response.status_code == 200 + assert "فقط به همین تیکت دسترسی دارید" in response.content.decode() + assert SESSION_KEY not in client.session + assert client.get(reverse("tickets:list")).status_code == 302