diff --git a/docs/plans/2026-02-20-profile-validation-design.md b/docs/plans/2026-02-20-profile-validation-design.md new file mode 100644 index 0000000..44ed450 --- /dev/null +++ b/docs/plans/2026-02-20-profile-validation-design.md @@ -0,0 +1,73 @@ +# Profile Form Validation Design + +## Problem + +The self-service (`/manage/profile`) and admin (`/admin/users/{id}/profile`) profile +forms have minimal validation: email only checks for `@`, phone number has no format +validation at all. Validation logic is duplicated inline in both route handlers. + +## Decision + +Use Pydantic types for field validation via a shared `ProfileUpdate` model. + +- **Email**: `pydantic.EmailStr` (uses `email-validator` under the hood) +- **Phone**: `pydantic_extra_types.phone_numbers.PhoneNumberValidator` with + `number_format="E164"` — accepts various input formats, normalizes to strict + E.164 (`+46701234567`). Backed by Google's `libphonenumber`. +- **Picture URL**: custom Pydantic validator (http/https scheme, has netloc) +- **Field lengths**: `Field(max_length=...)` on each field + +## New dependency + +`pydantic-extra-types[phonenumbers]` — brings in the `phonenumbers` package. + +## Shared validation module + +`src/porchlight/validation.py`: + +```python +from typing import Annotated +from pydantic import BaseModel, EmailStr, Field +from pydantic_extra_types.phone_numbers import PhoneNumberValidator + +E164Phone = Annotated[ + str, PhoneNumberValidator(number_format="E164") +] + +class ProfileUpdate(BaseModel): + given_name: str = Field(default="", max_length=255) + family_name: str = Field(default="", max_length=255) + preferred_username: str = Field(default="", max_length=255) + email: EmailStr | None = None + phone_number: E164Phone | None = None + picture: str = Field(default="", max_length=2048) # + URL validator + locale: str = Field(default="", max_length=20) +``` + +Route handlers instantiate `ProfileUpdate` from form data, catch `ValidationError`, +and return the first user-friendly error as the existing `HTMLResponse` with +`role="alert"`. + +## Route changes + +Both `manage/routes.py` and `admin/routes.py` profile POST handlers will: +1. Parse form data through `ProfileUpdate` +2. Use validated/normalized values from the model +3. Replace all inline validation with a single try/except + +## Template changes + +Add `pattern` and `title` attributes on the phone input for browser-native hints +about E.164 format. + +## Validation: server-side only + +No client-side JS validation. The HTML `type="email"` and `type="tel"` attributes +already provide basic browser hints. Server-side validation via htmx response +handles all error display. + +## Tests + +- Unit tests for `ProfileUpdate` (valid/invalid emails, phones, URLs, lengths) +- Integration tests for manage profile POST (happy path + validation errors) +- Existing admin tests may be tightened