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 %}
{{ site_branding.login_intro_eyebrow }}
{{ site_branding.login_intro_body|linebreaksbr }}
ورود مشتریان
شماره موبایل خود را وارد کنید؛ نیازی به ثبتنام یا رمز عبور نیست.
ورود مشتریان
شماره موبایل خود را وارد کنید؛ نیازی به ثبتنام یا رمز عبور نیست.
{% include 'includes/customer_login_block.html' %}ورود سریع
اگر قبلاً در پروفایل خود رمز تعیین کردهاید، بدون انتظار برای پیامک وارد شوید.
حساب مشتری
شماره موبایل و رمز انتخابی خود را وارد کنید.
حساب مشتری
شماره موبایل و رمز انتخابی خود را وارد کنید.
{% include 'includes/customer_login_block.html' %}