"""Validation-only forms for the shared LDAP profile; no directory writes.""" import unicodedata from django import forms from django.core.exceptions import ValidationError from .forms import PasswordForm MAX_INPUT_LENGTH = 32768 MAX_VALUES = 16 def _safe_text(value: str) -> str: # Normalize browser line endings before removing unsafe control characters. value = value.replace("\r\n", "\n").replace("\r", "\n").replace("\t", " ") return "".join( char for char in value if char == "\n" or unicodedata.category(char) not in {"Cc", "Cf", "Cs"} ) class ProfileValuesField(forms.CharField): """Text input becomes an ordered, deduplicated list of LDAP strings.""" def __init__(self, *, value_length: int = 2048, max_values: int = MAX_VALUES, **kwargs): self.value_length = value_length self.max_values = max_values kwargs.setdefault("required", False) kwargs.setdefault("help_text", "One value per line. Up to 16 values; leave blank to clear.") super().__init__( max_length=MAX_INPUT_LENGTH, widget=forms.Textarea(attrs={"rows": 3, "maxlength": MAX_INPUT_LENGTH}), **kwargs, ) def clean(self, value) -> list[str]: if value is not None and len(str(value)) > MAX_INPUT_LENGTH: raise ValidationError("Enter no more than 32,768 characters in this field.") text = super().clean(_safe_text(str(value)) if value is not None else "") entries = [line.strip() for line in text.split("\n") if line.strip()] if len(entries) > self.max_values: raise ValidationError(f"Enter no more than {self.max_values} value(s).") if any(len(entry) > self.value_length for entry in entries): raise ValidationError(f"Each value must be at most {self.value_length:,} characters.") if self.required and not entries: raise ValidationError("Enter at least one value.") return list(dict.fromkeys(entries)) def _current_password_field() -> forms.CharField: return forms.CharField( label="Current password", max_length=1024, strip=False, widget=forms.PasswordInput( render_value=False, attrs={"autocomplete": "current-password"} ), help_text="Enter your current eom.dev password to authorize this change.", ) class CurrentPasswordForm(forms.Form): current_password = _current_password_field() def __init__(self, *args, **kwargs): kwargs.setdefault("auto_id", "id_confirmation_%s") super().__init__(*args, **kwargs) class ProfileForm(CurrentPasswordForm): """Only Dex-visible values: cn is published to services as the "name" claim.""" cn = ProfileValuesField( label="Name", required=True, max_values=1, value_length=256, help_text="How you appear across eom.dev services.", ) field_order = ["cn", "current_password"] def __init__(self, *args, **kwargs): kwargs.setdefault("auto_id", "id_profile_%s") super().__init__(*args, **kwargs) self.fields["cn"].widget = forms.TextInput(attrs={"maxlength": 256, "autocomplete": "name"}) def identity_fields(self): return [self["cn"]] def authorization_fields(self): return [self["current_password"]] class ProfileEmailForm(CurrentPasswordForm): email = forms.EmailField(label="New email address", max_length=254, widget=forms.EmailInput(attrs={"autocomplete": "email"})) field_order = ["email", "current_password"] def __init__(self, *args, **kwargs): kwargs.setdefault("auto_id", "id_email_%s") super().__init__(*args, **kwargs) def clean_email(self) -> str: return self.cleaned_data["email"].casefold() class ProfilePasswordForm(PasswordForm, CurrentPasswordForm): field_order = ["current_password", "password", "confirm_password"] def __init__(self, *args, **kwargs): kwargs.setdefault("auto_id", "id_password_%s") super().__init__(*args, **kwargs) for name in ("password", "confirm_password"): self.fields[name].strip = False self.fields[name].widget = forms.PasswordInput( render_value=False, attrs={"autocomplete": "new-password"} )