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 @@