From e680fd6baf38c12ce8d48fb79fbd87a0fdd8a04c Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Mon, 10 Aug 2026 07:07:38 -0400 Subject: [PATCH] Make drop-down menu instead of modal for sign-in operations; fix #139, fix #122 --- .pre-commit-config.yaml | 33 ++-- efile_app/efile/api/auth_views.py | 14 +- efile_app/efile/api/suffolk_api_views.py | 15 +- efile_app/efile/authentication.py | 26 ++- efile_app/efile/forms.py | 20 ++- efile_app/efile/middleware.py | 26 +++ .../0003_jurisdiction_specific_accounts.py | 31 ++++ efile_app/efile/models.py | 17 +- efile_app/efile/settings_base.py | 1 + efile_app/efile/static/css/header.css | 60 ++++++- .../templates/efile/choose_jurisdiction.html | 2 +- .../efile/components/profile_header.html | 159 +++++++----------- .../efile/tests/test_jurisdiction_switch.py | 142 ++++++++++++++++ efile_app/efile/urls.py | 14 +- efile_app/efile/utils/account_ids.py | 12 ++ efile_app/efile/utils/jurisdiction_stuff.py | 24 +++ efile_app/efile/views/choose_jurisdiction.py | 15 +- efile_app/efile/views/login.py | 6 +- efile_app/efile/views/register.py | 4 +- 19 files changed, 481 insertions(+), 140 deletions(-) create mode 100644 efile_app/efile/migrations/0003_jurisdiction_specific_accounts.py create mode 100644 efile_app/efile/tests/test_jurisdiction_switch.py create mode 100644 efile_app/efile/utils/account_ids.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bc53e31..476de35 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,11 +18,12 @@ repos: files: ^efile_app/.*\.html$ args: - | + ROOT_DIR=$(git rev-parse --show-toplevel) files=() for file in "$@"; do - files+=("${file#efile_app/}") + files+=("$ROOT_DIR/$file") done - (cd efile_app && uv run djlint --reformat "${files[@]}") + uv run --directory "$ROOT_DIR/efile_app" djlint --reformat "${files[@]}" - -- - id: djlint-lint @@ -32,11 +33,12 @@ repos: files: ^efile_app/.*\.html$ args: - | + ROOT_DIR=$(git rev-parse --show-toplevel) files=() for file in "$@"; do - files+=("${file#efile_app/}") + files+=("$ROOT_DIR/$file") done - (cd efile_app && uv run djlint "${files[@]}") + uv run --directory "$ROOT_DIR/efile_app" djlint "${files[@]}" - -- - id: css-beautify @@ -46,11 +48,12 @@ repos: files: ^efile_app/.*\.css$ args: - | + ROOT_DIR=$(git rev-parse --show-toplevel) files=() for file in "$@"; do - files+=("${file#efile_app/}") + files+=("$ROOT_DIR/$file") done - (cd efile_app && uv run css-beautify -r "${files[@]}") + uv run --directory "$ROOT_DIR/efile_app" css-beautify -r "${files[@]}" - -- - id: js-beautify @@ -60,32 +63,38 @@ repos: files: ^efile_app/.*\.js$ args: - | + ROOT_DIR=$(git rev-parse --show-toplevel) files=() for file in "$@"; do - files+=("${file#efile_app/}") + files+=("$ROOT_DIR/$file") done - (cd efile_app && uv run js-beautify -r "${files[@]}") + uv run --directory "$ROOT_DIR/efile_app" js-beautify -r "${files[@]}" - -- - id: ty name: ty (type check) - entry: bash -lc + entry: bash -c language: system types: [python] pass_filenames: false args: - - cd efile_app && uv run ty check + - | + ROOT_DIR=$(git rev-parse --show-toplevel) + uv run --directory "$ROOT_DIR/efile_app" ty check # Run tests on pre-push to keep commits snappy - repo: local hooks: - id: pytest name: pytest (pre-push) - entry: bash -lc + entry: bash -c language: system stages: [pre-push] pass_filenames: false args: - - cd efile_app && uv run pytest -q + - | + ROOT_DIR=$(git rev-parse --show-toplevel) + uv run --directory "$ROOT_DIR/efile_app" pytest -q + # If needed, override Django settings here instead of pyproject: # - --ds=efile.settings diff --git a/efile_app/efile/api/auth_views.py b/efile_app/efile/api/auth_views.py index fef01fa..fa81469 100644 --- a/efile_app/efile/api/auth_views.py +++ b/efile_app/efile/api/auth_views.py @@ -41,9 +41,19 @@ def user_login(request): user = authenticate(request, username=username, password=password, jurisdiction=jurisdiction) if user is not None: + auth_tokens = request.session.get("auth_tokens", {}) + request.session.flush() login(request, user) + request.session["auth_tokens"] = auth_tokens + request.session["user_email"] = user.email + request.session["jurisdiction"] = jurisdiction return AuthAPIViews.success_response( - {"user_id": user.id, "username": user.username, "email": user.email, "is_authenticated": True}, + { + "user_id": user.id, + "username": user.account_email, + "email": user.email, + "is_authenticated": True, + }, "Login successful", ) else: @@ -132,7 +142,7 @@ def user_profile(request): "external_firm_data": external_data, # Local user data (if authenticated) "id": request.user.id if request.user.is_authenticated else None, - "username": request.user.username if request.user.is_authenticated else "guest", + "username": request.user.account_email if request.user.is_authenticated else "guest", "email": request.user.email if request.user.is_authenticated else request.session.get("user_email"), "first_name": self_json["firstName"], "last_name": self_json["lastName"], diff --git a/efile_app/efile/api/suffolk_api_views.py b/efile_app/efile/api/suffolk_api_views.py index de75c5a..5eee0d1 100644 --- a/efile_app/efile/api/suffolk_api_views.py +++ b/efile_app/efile/api/suffolk_api_views.py @@ -13,7 +13,7 @@ from requests.exceptions import RequestException from efile.utils.case_data_utils import get_case_data, update_case_data -from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request +from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request, get_jurisdiction_token from efile.utils.proxy_connection import get_headers logger = logging.getLogger(__name__) @@ -24,18 +24,7 @@ def get_tyler_token(request, jurisdiction=None): if jurisdiction is None: jurisdiction = get_jurisdiction_from_request(request) - # Fallback to session - auth_tokens = request.session.get("auth_tokens", {}) - logger.debug(f"Auth tokens in session: {auth_tokens}") - - # Try different Tyler token key formats - tyler_token = ( - auth_tokens.get(f"TYLER-TOKEN-{jurisdiction.upper()}") - or auth_tokens.get(f"tyler_token_{jurisdiction}") - or auth_tokens.get(f"tyler-token-{jurisdiction}") - ) - - return tyler_token + return get_jurisdiction_token(request, jurisdiction) def set_access_control_headers(response): diff --git a/efile_app/efile/authentication.py b/efile_app/efile/authentication.py index dc679c5..966666c 100644 --- a/efile_app/efile/authentication.py +++ b/efile_app/efile/authentication.py @@ -3,6 +3,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.backends import BaseBackend +from efile.utils.account_ids import jurisdiction_account_username from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request from efile.utils.proxy_connection import auth_with_tyler_api @@ -47,28 +48,37 @@ def get_user(self, user_id): return None def _get_or_create_user(self, username, auth_data, jurisdiction): - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - user = None + user = User.objects.filter( + tyler_username__iexact=username, + tyler_jurisdiction__iexact=jurisdiction, + ).first() + if user is None: + # Accounts created before ``tyler_username`` was introduced stored the + # Tyler login directly in Django's username field. + user = User.objects.filter( + username__iexact=username, + tyler_jurisdiction__iexact=jurisdiction, + tyler_username="", + ).first() user_data = self._extract_user_data(auth_data, username, jurisdiction) if not user: user = User.objects.create_user( - username=username, + username=jurisdiction_account_username(username, jurisdiction), tyler_jurisdiction=jurisdiction, + tyler_username=username, tyler_user_id=user_data.get("user_id", None), email=user_data.get("email", username), first_name=user_data.get("first_name", ""), last_name=user_data.get("last_name", ""), ) else: - user.tyler_jurisdiction = jurisdiction + user.tyler_username = username user.tyler_user_id = user_data.get("user_id", None) user.email = user_data.get("email", username) - user.save() + user.save(update_fields=["tyler_username", "tyler_user_id", "email", "updated_at"]) - logger.info("Created new user: %s, %s", user.username, user.email) + logger.info("Authenticated local account %s for %s", user.pk, jurisdiction) return user def _extract_user_data(self, auth_data, username, jurisdiction): diff --git a/efile_app/efile/forms.py b/efile_app/efile/forms.py index 785e965..d8a314e 100644 --- a/efile_app/efile/forms.py +++ b/efile_app/efile/forms.py @@ -1,5 +1,8 @@ from django import forms from django.contrib.auth import get_user_model +from django.db.models import Q + +from efile.utils.account_ids import jurisdiction_account_username User = get_user_model() @@ -22,6 +25,10 @@ class EFilePasswordResetForm(forms.Form): class EFileRegistrationForm(forms.Form): + def __init__(self, *args, jurisdiction=None, **kwargs): + super().__init__(*args, **kwargs) + self.jurisdiction = jurisdiction + # Legal Name first_name = forms.CharField( max_length=100, @@ -135,7 +142,12 @@ class EFileRegistrationForm(forms.Form): def clean_email(self): email = self.cleaned_data["email"] - if User.objects.filter(email=email).exists(): + existing_accounts = User.objects.filter( + Q(tyler_username__iexact=email) | Q(username__iexact=email) | Q(email__iexact=email) + ) + if self.jurisdiction: + existing_accounts = existing_accounts.filter(tyler_jurisdiction__iexact=self.jurisdiction) + if existing_accounts.exists(): raise forms.ValidationError("A user with this email already exists.") return email @@ -160,9 +172,13 @@ def clean(self): return cleaned_data def save(self): + if not self.jurisdiction: + raise ValueError("A jurisdiction is required to create an eFile account") user = User.objects.create_user( - username=self.cleaned_data["email"], + username=jurisdiction_account_username(self.cleaned_data["email"], self.jurisdiction), email=self.cleaned_data["email"], + tyler_username=self.cleaned_data["email"], + tyler_jurisdiction=self.jurisdiction, password=self.cleaned_data["password"], first_name=self.cleaned_data["first_name"], last_name=self.cleaned_data["last_name"], diff --git a/efile_app/efile/middleware.py b/efile_app/efile/middleware.py index 15d338f..f0da257 100644 --- a/efile_app/efile/middleware.py +++ b/efile_app/efile/middleware.py @@ -1,3 +1,29 @@ +from django.contrib.auth import logout + +from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request + + +class JurisdictionSessionMiddleware: + """End an authenticated session before it can cross a jurisdiction boundary.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + jurisdiction = get_jurisdiction_from_request(request) + user = getattr(request, "user", None) + account_jurisdiction = getattr(user, "tyler_jurisdiction", "") + if ( + jurisdiction + and getattr(user, "is_authenticated", False) + and account_jurisdiction + and account_jurisdiction.casefold() != jurisdiction.casefold() + ): + logout(request) + + return self.get_response(request) + + class NoCacheHTMLMiddleware: """Stop browsers from reusing a stale rendered page without asking the server first. diff --git a/efile_app/efile/migrations/0003_jurisdiction_specific_accounts.py b/efile_app/efile/migrations/0003_jurisdiction_specific_accounts.py new file mode 100644 index 0000000..924b331 --- /dev/null +++ b/efile_app/efile/migrations/0003_jurisdiction_specific_accounts.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +def copy_existing_tyler_usernames(apps, schema_editor): + UserProfile = apps.get_model("efile", "UserProfile") + for user in UserProfile.objects.exclude(tyler_jurisdiction="").iterator(): + user.tyler_username = user.username + user.save(update_fields=["tyler_username"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("efile", "0002_filing_drafts"), + ] + + operations = [ + migrations.AddField( + model_name="userprofile", + name="tyler_username", + field=models.CharField(blank=True, max_length=254), + ), + migrations.RunPython(copy_existing_tyler_usernames, migrations.RunPython.noop), + migrations.AddConstraint( + model_name="userprofile", + constraint=models.UniqueConstraint( + condition=~models.Q(tyler_username=""), + fields=("tyler_jurisdiction", "tyler_username"), + name="unique_tyler_account_per_jurisdiction", + ), + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index 0b37f82..3b643c2 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -12,9 +12,11 @@ class UserProfile(AbstractUser): Extended user profile to store eFile registration information. """ - # TODO(brycew): what happens if someone is trying to do stuff in multiple jurisdictions? + # Tyler identities are jurisdiction-specific; ``tyler_username`` may repeat + # across jurisdictions while each row remains a separate local account. tyler_jurisdiction = models.CharField(max_length=20) tyler_user_id = models.CharField(max_length=100, blank=True, null=True) + tyler_username = models.CharField(max_length=254, blank=True) # TODO(brycew): uncomment when https://github.com/SuffolkLITLab/EfileProxyServer/issues/334 is in # token_expires_at = models.DateTimeField(blank=True, null=True) @@ -30,6 +32,19 @@ class UserProfile(AbstractUser): class Meta: verbose_name = "User Profile" verbose_name_plural = "User Profiles" + constraints = [ + models.UniqueConstraint( + fields=["tyler_jurisdiction", "tyler_username"], + condition=~models.Q(tyler_username=""), + name="unique_tyler_account_per_jurisdiction", + ) + ] + + @property + def account_email(self): + """The external Tyler login shown to the user, never the internal username.""" + + return self.tyler_username or self.email or self.username class FilingDraft(models.Model): diff --git a/efile_app/efile/settings_base.py b/efile_app/efile/settings_base.py index 0dc196a..7c99849 100644 --- a/efile_app/efile/settings_base.py +++ b/efile_app/efile/settings_base.py @@ -42,6 +42,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "efile.middleware.JurisdictionSessionMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "efile.middleware.NoCacheHTMLMiddleware", diff --git a/efile_app/efile/static/css/header.css b/efile_app/efile/static/css/header.css index 5d7f7fa..9161b86 100644 --- a/efile_app/efile/static/css/header.css +++ b/efile_app/efile/static/css/header.css @@ -11,9 +11,10 @@ font-weight: 500; } -.profile-icon { +.profile-menu-toggle { width: 40px; height: 40px; + border: 2px solid transparent; background-color: white; border-radius: 50%; display: flex; @@ -23,6 +24,63 @@ cursor: pointer; } +.profile-menu-toggle:hover { + background-color: #f3f4f6; +} + +.profile-menu-toggle:focus-visible { + border-color: white; + outline: 3px solid var(--suffolk-blue); + outline-offset: 2px; +} + +.profile-menu { + width: min(22rem, calc(100vw - 2rem)); + margin-top: 0.5rem !important; + padding: 0.5rem 0; + border: 0; + box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 18%); +} + +.profile-menu-summary { + padding: 0.75rem 1rem; + color: #1f2937; +} + +.profile-menu-label, +.profile-menu-name, +.profile-menu-detail { + margin: 0; +} + +.profile-menu-label, +.profile-menu-detail { + color: #4b5563; + font-size: 0.875rem; +} + +.profile-menu-name { + overflow-wrap: anywhere; + font-weight: 700; +} + +.profile-menu-detail+.profile-menu-detail { + margin-top: 0.25rem; +} + +.profile-menu .dropdown-item { + display: flex; + align-items: center; + gap: 0.75rem; + min-height: 44px; + white-space: normal; +} + +.profile-menu .dropdown-item i { + width: 1.25rem; + text-align: center; +} + .scales-icon { font-size: 1.2rem; margin-right: 0.5rem; diff --git a/efile_app/efile/templates/efile/choose_jurisdiction.html b/efile_app/efile/templates/efile/choose_jurisdiction.html index f4eed2c..983edb2 100644 --- a/efile_app/efile/templates/efile/choose_jurisdiction.html +++ b/efile_app/efile/templates/efile/choose_jurisdiction.html @@ -35,7 +35,7 @@

{{ the_title }}

align-items:center"> {% for value in jurisdiction_details %}
- -
+
diff --git a/efile_app/efile/tests/test_jurisdiction_switch.py b/efile_app/efile/tests/test_jurisdiction_switch.py new file mode 100644 index 0000000..7db1107 --- /dev/null +++ b/efile_app/efile/tests/test_jurisdiction_switch.py @@ -0,0 +1,142 @@ +from unittest.mock import patch + +import pytest +from django.urls import reverse + +from efile.authentication import SuffolkEFileBackend +from efile.models import FilingDraft +from efile.services.current_drafts import CURRENT_DRAFT_SESSION_KEY + + +def _set_jurisdiction_session(client, jurisdiction, **extra): + session = client.session + session["auth_tokens"] = {f"TYLER-TOKEN-{jurisdiction.upper()}": f"{jurisdiction}-token"} + session["jurisdiction"] = jurisdiction + session.update(extra) + session.save() + + +@pytest.fixture +def illinois_account(client, django_user_model): + user = django_user_model.objects.create_user( + username="illinois:local-account", + email="filer@example.com", + tyler_username="filer@example.com", + tyler_jurisdiction="illinois", + first_name="Alex", + last_name="Filer", + ) + client.force_login(user) + _set_jurisdiction_session(client, "illinois") + return user + + +@pytest.mark.django_db +def test_profile_control_is_an_accessible_dropdown(client, illinois_account): + response = client.get(reverse("efile_options", kwargs={"jurisdiction": "illinois"})) + content = response.content.decode() + + assert response.status_code == 200 + assert 'data-bs-toggle="dropdown"' in content + assert 'aria-label="Open profile menu"' in content + assert "Alex Filer" in content + assert "filer@example.com" in content + assert "File in a different state" in content + assert "profileModal" not in content + assert 'class="modal fade"' not in content + + +@pytest.mark.django_db +def test_changing_jurisdiction_clears_auth_and_active_filing_session(client, illinois_account): + draft = FilingDraft.objects.create(user=illinois_account, jurisdiction="illinois") + _set_jurisdiction_session( + client, + "illinois", + **{ + CURRENT_DRAFT_SESSION_KEY: draft.pk, + "case_data": {"court": "cook:law1"}, + "upload_data": {"files": {"lead": {"name": "petition.pdf"}}}, + "unrelated_stale_key": "must-not-cross-state-lines", + }, + ) + + response = client.post(reverse("change_jurisdiction", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 302 + assert response.url == reverse("efile_choose_jurisdiction") + assert list(client.session.keys()) == [] + assert FilingDraft.objects.filter(pk=draft.pk, jurisdiction="illinois").exists() + + +@pytest.mark.django_db +def test_state_picker_requires_login_for_new_jurisdiction(client, illinois_account): + response = client.get(reverse("jurisdiction_homepage", kwargs={"jurisdiction": "massachusetts"})) + + assert response.status_code == 302 + assert response.url == reverse("efile_login", kwargs={"jurisdiction": "massachusetts"}) + assert list(client.session.keys()) == [] + + +@pytest.mark.django_db +def test_state_picker_reuses_only_matching_active_login(client, illinois_account): + response = client.get(reverse("jurisdiction_homepage", kwargs={"jurisdiction": "illinois"})) + + assert response.status_code == 302 + assert response.url == reverse("efile_options", kwargs={"jurisdiction": "illinois"}) + + +@pytest.mark.django_db +def test_same_tyler_email_creates_separate_jurisdiction_accounts(): + backend = SuffolkEFileBackend() + username = "same-filer@example.com" + + illinois_user = backend._get_or_create_user( + username, + {"tokens": {"TYLER-ID-ILLINOIS": "il-id", "TYLER-TOKEN-ILLINOIS": "il-token"}}, + "illinois", + ) + massachusetts_user = backend._get_or_create_user( + username, + { + "tokens": { + "TYLER-ID-MASSACHUSETTS": "ma-id", + "TYLER-TOKEN-MASSACHUSETTS": "ma-token", + } + }, + "massachusetts", + ) + + assert illinois_user.pk != massachusetts_user.pk + assert illinois_user.tyler_jurisdiction == "illinois" + assert massachusetts_user.tyler_jurisdiction == "massachusetts" + assert illinois_user.tyler_username == massachusetts_user.tyler_username == username + assert illinois_user.username != massachusetts_user.username + + +@pytest.mark.django_db +@patch("efile.authentication.auth_with_tyler_api") +def test_login_starts_a_clean_jurisdiction_session(mock_auth, client): + mock_auth.return_value = { + "tokens": { + "TYLER-ID-MASSACHUSETTS": "ma-id", + "TYLER-TOKEN-MASSACHUSETTS": "ma-token", + } + } + session = client.session + session["case_data"] = {"jurisdiction": "illinois"} + session[CURRENT_DRAFT_SESSION_KEY] = 987 + session["jurisdiction"] = "illinois" + session.save() + + response = client.post( + reverse("efile_login", kwargs={"jurisdiction": "massachusetts"}), + {"login_submit": "1", "email": "new-filer@example.com", "password": "secret-password"}, + ) + + assert response.status_code == 302 + assert response.url == reverse("efile_options", kwargs={"jurisdiction": "massachusetts"}) + assert "case_data" not in client.session + assert CURRENT_DRAFT_SESSION_KEY not in client.session + assert client.session["jurisdiction"] == "massachusetts" + assert client.session["auth_tokens"] == mock_auth.return_value["tokens"] + assert client.session["user_email"] == "new-filer@example.com" diff --git a/efile_app/efile/urls.py b/efile_app/efile/urls.py index 8fe82ed..16edb46 100644 --- a/efile_app/efile/urls.py +++ b/efile_app/efile/urls.py @@ -2,8 +2,11 @@ from django.urls import include, path from django.views.i18n import JavaScriptCatalog +from efile.utils.config_loader import config_loader +from efile.utils.jurisdiction_stuff import has_jurisdiction_login + from .views.api_views import get_case_data_api, get_filing_components -from .views.choose_jurisdiction import choose_jurisdiction +from .views.choose_jurisdiction import change_jurisdiction, choose_jurisdiction from .views.confirmation import filing_confirmation from .views.draft_views import create_draft_view, get_current_draft_view from .views.expert_form import efile_expert_form @@ -33,6 +36,10 @@ def homepage(request): def jurisdiction_homepage(request, jurisdiction): + if jurisdiction not in config_loader.get_available_jurisdictions(): + return redirect("efile_choose_jurisdiction") + if has_jurisdiction_login(request, jurisdiction): + return redirect("efile_options", jurisdiction) return redirect("efile_login", jurisdiction) @@ -43,6 +50,11 @@ def jurisdiction_homepage(request, jurisdiction): path("jurisdiction/", jurisdiction_homepage, name="jurisdiction_homepage"), path("jurisdiction//login/", efile_login, name="efile_login"), path("jurisdiction//logout/", efile_logout, name="efile_logout"), + path( + "jurisdiction//change-jurisdiction/", + change_jurisdiction, + name="change_jurisdiction", + ), path("jurisdiction//register/", efile_register, name="efile_register"), path("jurisdiction//password_reset/", efile_password_reset, name="efile_password_reset"), path("jurisdiction//options/", efile_options, name="efile_options"), diff --git a/efile_app/efile/utils/account_ids.py b/efile_app/efile/utils/account_ids.py new file mode 100644 index 0000000..d56ab7a --- /dev/null +++ b/efile_app/efile/utils/account_ids.py @@ -0,0 +1,12 @@ +"""Stable local identifiers for jurisdiction-specific Tyler accounts.""" + +import hashlib + + +def jurisdiction_account_username(external_username: str, jurisdiction: str) -> str: + """Return a non-sensitive, globally unique Django username for a Tyler account.""" + + normalized_username = external_username.strip().casefold() + normalized_jurisdiction = jurisdiction.strip().casefold() + digest = hashlib.sha256(normalized_username.encode()).hexdigest() + return f"{normalized_jurisdiction}:{digest}" diff --git a/efile_app/efile/utils/jurisdiction_stuff.py b/efile_app/efile/utils/jurisdiction_stuff.py index 3d698c9..0305c83 100644 --- a/efile_app/efile/utils/jurisdiction_stuff.py +++ b/efile_app/efile/utils/jurisdiction_stuff.py @@ -8,3 +8,27 @@ def get_jurisdiction_from_request(request): return segments[2].lower() return request.session.get("jurisdiction") + + +def get_jurisdiction_token(request, jurisdiction): + """Return the Tyler token for exactly one jurisdiction, if this session has it.""" + + if not jurisdiction: + return None + auth_tokens = request.session.get("auth_tokens", {}) + return ( + auth_tokens.get(f"TYLER-TOKEN-{jurisdiction.upper()}") + or auth_tokens.get(f"tyler_token_{jurisdiction}") + or auth_tokens.get(f"tyler-token-{jurisdiction}") + ) + + +def has_jurisdiction_login(request, jurisdiction): + """Whether the active Django and Tyler identities both belong to ``jurisdiction``.""" + + user = getattr(request, "user", None) + return bool( + getattr(user, "is_authenticated", False) + and getattr(user, "tyler_jurisdiction", "").casefold() == jurisdiction.casefold() + and get_jurisdiction_token(request, jurisdiction) + ) diff --git a/efile_app/efile/views/choose_jurisdiction.py b/efile_app/efile/views/choose_jurisdiction.py index c7afcc6..27ef181 100644 --- a/efile_app/efile/views/choose_jurisdiction.py +++ b/efile_app/efile/views/choose_jurisdiction.py @@ -1,13 +1,24 @@ import logging -from django.shortcuts import render +from django.contrib.auth import logout +from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed +from django.shortcuts import redirect, render from efile.utils.config_loader import config_loader logger = logging.getLogger(__name__) -def choose_jurisdiction(request): +def choose_jurisdiction(request: HttpRequest) -> HttpResponse: jurisdictions = config_loader.get_available_jurisdictions() jurisdiction_details = [config_loader.load_jurisdiction_config(j) for j in jurisdictions] return render(request, "efile/choose_jurisdiction.html", {"jurisdiction_details": jurisdiction_details}) + + +def change_jurisdiction(request: HttpRequest, jurisdiction: str) -> HttpResponse: + """Discard the active account/filing context before showing the state picker.""" + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + logout(request) + return redirect("efile_choose_jurisdiction") diff --git a/efile_app/efile/views/login.py b/efile_app/efile/views/login.py index ea5d736..ee15c8c 100644 --- a/efile_app/efile/views/login.py +++ b/efile_app/efile/views/login.py @@ -31,11 +31,15 @@ def efile_login(request, jurisdiction): email = login_form.cleaned_data["email"] password = login_form.cleaned_data["password"] try: - user = authenticate(request, username=email, password=password) + user = authenticate(request, username=email, password=password, jurisdiction=jurisdiction) if user is not None: # response.status_code == 200: + auth_tokens = request.session.get("auth_tokens", {}) + request.session.flush() login(request, user) + request.session["auth_tokens"] = auth_tokens request.session["user_email"] = user.email + request.session["jurisdiction"] = jurisdiction messages.success(request, "Successfully logged in!") return redirect(f"/jurisdiction/{jurisdiction}/options/") else: diff --git a/efile_app/efile/views/register.py b/efile_app/efile/views/register.py index f23d004..59f78d8 100644 --- a/efile_app/efile/views/register.py +++ b/efile_app/efile/views/register.py @@ -16,7 +16,7 @@ def efile_register(request, jurisdiction): if jurisdiction not in config_loader.get_available_jurisdictions(): return redirect("efile_choose_jurisdiction") if request.method == "POST": - form = EFileRegistrationForm(request.POST) + form = EFileRegistrationForm(request.POST, jurisdiction=jurisdiction) required_fields = [ "first_name", "last_name", @@ -135,6 +135,6 @@ def check_password_strength(pw): messages.error(request, gettext("Please correct the errors below.")) else: # Always show a blank form on reload - form = EFileRegistrationForm() + form = EFileRegistrationForm(jurisdiction=jurisdiction) config = config_loader.load_jurisdiction_config(jurisdiction) return render(request, "efile/register.html", {"form": form, "config": config})