Files
www/membership/members/profile_forms.py
T
claudeandClaude Opus 5.5 d73f7d1b80 Profile: only edit fields Dex reads back
The Dex LDAP connector publishes uid, cn and mail as claims, so the
profile page now offers just Name (cn), the verified email change and
the password. The photo endpoint, SSH keys, addresses, phones and
other extended attributes are no longer read or written; existing LDAP
values are left untouched. Apache's profile upload limit drops back to
the 256 KiB used by other member routes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:42:57 -05:00

114 lines
4.3 KiB
Python
Executable File

"""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"}
)