Author SHA1 Message Date
Ebbe BaßandClaude Sonnet 5.5 3fd8f27127 feat: add profile edit
Profile page at /profile with first name, last name, display name, telephone and mobile read from AD and shown with the username and e-mail. Only changed fields are written through the directory adapter in one transaction with the audit entry (field names only); numbers are accepted in any common notation and stored as +country code and digits; names are required. Plain-language errors per field, German text and tests; review findings logged in the plan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FocPJ75XPma7TcuYEMy8zm
2026-10-19 09:03:24 +02:00
14 changed files with 1055 additions and 5 deletions
@@ -19,3 +19,6 @@
- source_plan: `_bmad-output/initiative-ping-mee-portal/epic-account-self-service/story-password-change-plan.md`
summary: Known limitation, accepted in the code review of 2026-10-19: failed binds made elsewhere (authentik, other devices) count toward AD's lockout but not toward the portal's limit.
evidence: Already stated in the plan's design notes. Fixing it needs a new directory read of the bad-password count (new adapter surface).
- source_plan: `_bmad-output/initiative-ping-mee-portal/epic-account-self-service/story-profile-edit-plan.md`
summary: A `Forbidden` from the directory (service account lacks a right) is not mapped to a message in the password change either, so the user gets a raw 500 page.
evidence: Found reviewing profile edit; `LdapDirectory._service` raises `Forbidden` on INSUFFICIENT_ACCESS and `services.change_password` / `views.password` catch only DirectoryUnavailable, PasswordRejected and OutcomeUnknown. Pre-existing from the password change; fix in one place (AD-17) for every service.
@@ -0,0 +1,132 @@
---
title: 'Profile edit'
type: 'feature'
ticket: '2'
epic: 'epic-account-self-service'
created: '2026-10-19'
status: 'built'
baseline_revision: 'ae4b30ec22d927bf1fb9e2a9ba32d6727c6935bb'
route: 'full'
route_source: 'auto'
risk: 'medium'
review: 'quick'
review_source: 'pinned'
lenses_ran: ['quick']
review_loop_iteration: 0
context:
- '{project-root}/_bmad-output/architecture/architecture-ping-mee-self-service-platform-2026-10-15/ARCHITECTURE-SPINE.md'
- '{project-root}/_bmad-output/ux-designs/ux-ping-mee-self-service-platform-2026-10-15/EXPERIENCE.md'
---
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
## Intent
**Problem:** A name or phone number still changes only when the admin edits it in AD. A signed-in user cannot see the details the directory holds about them, correct them, or be sure what was saved.
**Approach:** One page at `/profile` in the `profile` app. It shows the username and e-mail address (read only) and five fields filled from AD: first name, last name, display name, telephone and mobile. A service `update_details` checks the values, sends only the changed ones through the directory adapter's `update_attributes`, and writes the audit entry in the same transaction. The e-mail address is changed by ticket 3, not here.
## Boundaries & Constraints
**Always:** AD-2, AD-3, AD-4, AD-17 apply. The acting user is read from the session only; the view and service accept no username, DN or user id, and a posted `username` field is ignored. Only `givenName`, `sn`, `displayName`, `telephoneNumber` and `mobile` are ever written, and values travel only as attribute values, never inside a filter or DN. Values are trimmed. First name, last name and display name are required and may hold any printable text; control characters (newline, tab, NUL) are refused. Length limits are AD's: 64 for first name and last name, 256 for display name. Telephone and mobile are optional and are stored in one format, `+` and digits only (see Design Notes): the input may be written freely, a leading `0` is read as Germany (`+49`), and anything that is not a number is refused. A field whose text equals the AD value is left alone: it is neither checked nor rewritten. A save with no change makes no AD call and no audit entry. The audit entry holds the names of the changed fields and never their values. A failed AD call rolls back and writes a failure entry after the rollback. Messages are plain words and name the field. Every new string has German formal text, informal where it differs, in `portal/locale`. The form works as a plain POST.
**Never:** No e-mail edit and no link to one (ticket 3). No password, picture or group change. No attribute outside the five. No raw directory message shown to the user. No password re-check for these fields.
## I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| Open the page | Signed-in user | Five fields with the AD values, username and e-mail as plain text; menu entry "Profile" active | Directory down: "not possible now" page (503), no form |
| Save changes | Valid values, at least one differs from AD | Only the changed attributes written; `profile.updated` audited with the names of the changed fields only; redirect to the page with a "saved" notice; this session's shown name updated | n/a |
| Nothing changed | Same values as in AD | No AD call, no audit; notice "Nothing to change" | n/a |
| Clear a number | Telephone or mobile left empty | The attribute is cleared in AD | n/a |
| Number written freely | `0171 / 123456`, `0171123456`, `+49 171 123456`, `+49 (0) 171-123456`, `0049 171 123456` | Stored as `+49171123456`; the page shows the stored form after the save | n/a |
| Number from another country | `+43 660 1234567`, `0043 660 1234567` | Stored as `+436601234567` | n/a |
| Not a number | Letters, a `+` inside the number, no `0` or `+` at the start (`171123456`), fewer than 7 or more than 15 digits | Error under the field: the number must start with `+` and the country code, or with `0`; nothing written | Not audited |
| Required name empty | First name, last name or display name empty | Error under the field; nothing written | Not audited |
| Invalid value | Too long, or a control character | Error under the field and in a summary; nothing written; the other typed values stay in the form | Not audited |
| Injection strings | `*)(uid=*`, `\00`, `CN=x,DC=y`, a newline | Stored as plain text where allowed or refused by the rules; never alter a query; no other object or attribute changes | n/a |
| Another user named | A posted `username` or `sam` | Ignored; the session user is edited | n/a |
| AD refuses a value | Syntax or constraint error from AD | "A value was not accepted. Please check your entries." Nothing changed; failure audited | Transaction rolled back |
| Directory down on save | AD unavailable | "This is not possible right now. Please try again later." (503); failure audited | Transaction rolled back |
| Not signed in, wrong method, no CSRF token | Any request | Redirect to sign-in; 405; 403; nothing changes | n/a |
</frozen-after-approval>
## Code Map
- `portal/profile/forms.py` -- add `DetailsForm` (five `CharField`s, `strip=True`, `max_length` per field, the three names required, a control-character check, `normalize_phone` for the two number fields). `PasswordChangeForm` stays.
- `portal/profile/services.py` -- add `load_details(user)` (directory read to initial values) and `update_details(user, values, *, ip)` (diff against a fresh read, `transaction.atomic` with `directory.update_attributes` and `audit.record`, failure entry after rollback). Reuse `_record_failure` style and the typed errors `DirectoryUnavailable`, `ValidationFailed`.
- `portal/profile/views.py`, `urls.py` -- add the `profile` view (GET, POST) at `/profile`, name `profile`; `login_required`; one-time session flag for the notice as the password page does.
- `portal/templates/profile/details.html`, a field partial for text inputs (`_field.html` is password-only: `type="password"`).
- `portal/shell/menu.py` -- add `Entry(_("Profile"), "profile")` after Home.
- `portal/accounts/identity.py` (`remember`, `_display_name`), `session_store.DISPLAY_NAME` -- the session caches the display name for 60 s (`ROLE_CACHE_SECONDS`); refresh it in the saving session, other sessions follow on their own.
- `portal/adapters/directory/` -- `update_attributes` already exists in the interface, `FakeDirectory` and `LdapDirectory` (`MOD_REPLACE`, empty value clears, allow-list enforced, NUL refused). Reuse unchanged.
- `portal/locale/de`, `de_DU` -- German text; `portal/tests/test_translations.py` checks coverage.
- Do not change: the password flow, `accounts.throttle`, the adapter.
## Tasks & Acceptance
**Execution:**
- [ ] `portal/profile/forms.py` -- `DetailsForm` and `normalize_phone` -- FR-9
- [ ] `portal/profile/services.py` -- `load_details`, `update_details` -- AD-2, AD-3, AD-4
- [ ] `portal/profile/views.py`, `urls.py`, `portal/templates/profile/*`, `portal/shell/menu.py` -- page, notice, error mapping to messages and status codes, menu entry -- AD-17
- [ ] `portal/locale/de`, `de_DU` -- German text for every new string
- [ ] `portal/tests/test_profile_edit.py` -- every matrix row with the fake directory, plus: a table of phone inputs and their stored form, an unchanged legacy value left alone, injection strings, a posted username ignored, another user's data untouched, a rolled-back write leaves no audit entry, the session name refreshed
**Acceptance Criteria:**
- Given a signed-in user, when they open `/profile`, then the five fields hold their AD values and the username and e-mail are shown without an input.
- Given valid new values, when they save, then the matching AD attributes hold them, one audit entry exists, and nothing else about the user changed.
- Given invalid values, when they save, then the message names the field and nothing is written (the directory is read once, to tell a changed value from an old one).
- Given any request, then no username, DN or user id is accepted from the client.
- Given a failure after the AD call inside the transaction, then the error is logged at error level with the change details (AD-4) and the user is told it is unclear.
## Implementation Notes
- Built by a subagent from this plan, checked against the diff. `pytest portal/tests` 423 passed, 6 skipped; `ruff check` clean. The done checkpoint against the test OU is not run.
- The checks (required, control characters, length, phone) run in `forms.check_details`, called from `services.update_details`, not in `DetailsForm`: they need the directory value to leave an unchanged old value alone. `DetailsForm` only trims. Consequence: an invalid save reads the directory once; nothing is written or audited.
- A required name that is empty in AD and left empty is "unchanged" and passes; only a changed value is checked.
- On a refused or invalid save the page re-reads the directory for the username and e-mail rows; if that read fails the rows are left out of that response.
- Failure entries: `profile.update_failed` with reason `unavailable`, `refused`, `error` or `unclear`.
## Plan Change Log
## Review Triage Log
Pass 1 (quick lens): 0 high, 1 medium, 2 low patched, 3 low rejected.
| Finding | Verdict | Route | Evidence |
|---|---|---|---|
| Other adapter errors (`Forbidden` when the service account lacks the write right) are audited and re-raised, so the user gets a raw 500 page | medium | patch | `LdapDirectory._service` turns `INSUFFICIENT_ACCESS` into `Forbidden`; `update_details` and `profile()` catch neither. Map it to the unavailable page, audit `forbidden`, add a test. |
| An invalid save reads the directory twice (`update_details`, then `_details_or_none`) | low | patch | Real; the acceptance line says once, and the two reads can disagree. `DetailsInvalid` and `DetailsNotAccepted` carry the details already read. |
| Step label `audit` is also logged when the commit itself fails | low | patch | `step` is set before `audit.record` and never advanced; set `commit` after the record. |
| Saving session's name not refreshed after an unclear outcome | low | rejected | Stale for at most 60 s (`ROLE_CACHE_SECONDS`); the user sees the unclear page; the fix adds a branch for no real harm. |
| Phone value with a newline gets the control-character message; length checked before normalizing | low | rejected | The message is true and the value is refused either way; 64 characters of separators is not met in use. |
| Missing tests (single read, unmapped errors, newline in a phone, name after unclear) | low | patch for the first two, rejected for the rest | Covered by the two patches above; the others test the rejected findings. |
## Design Notes
**Why compare with a fresh read.** The form is filled from AD when the page opens; the save reads AD again and sends only the fields that differ from that read. A change made elsewhere in between is not overwritten by an unchanged field.
**Phone numbers.** Remove spaces, `-`, `/`, `.` and brackets; drop a `(0)` that follows a country code. Then: `+` and digits stay as they are; `00` becomes `+`; a single leading `0` becomes `+49` and the `0` is dropped; anything else is refused. A `+` anywhere but the start, a letter, or a result of fewer than 7 or more than 15 digits is refused. No check that the country code exists.
```
0171 / 123456 -> +49171123456
+49 (0) 171-123456 -> +49171123456
0049 171 123456 -> +49171123456
171123456 -> refused (no 0 or + at the start)
```
**Values already in AD.** Numbers entered by the admin may be in another format or not a number at all ("intern 23"). The form shows them as they are; a field left as it is passes unchanged, so one old value never blocks saving another field.
**No throttle, no password.** The password page needs both because a wrong password reaches AD's lockout. These fields are a plain authenticated write, so only the session and CSRF protect them.
## Verification
**Commands:**
- `.venv/Scripts/python -m pytest portal/tests -q` -- expected: all tests pass, including the new ones
- `.venv/Scripts/python -m ruff check portal manage.py docker` -- expected: no findings
**Manual checks (done checkpoint, on the Linux host against the test OU):**
- Sign in as `ptest-user1`, change each of the five fields, and see the values in AD (`Get-ADUser -Properties givenName,sn,displayName,telephoneNumber,mobile`).
- Submit an injection string and confirm only the one attribute holds it.
+67
View File
@@ -247,3 +247,70 @@ msgstr "Wenn Sie das waren, müssen Sie nichts tun. Wenn nicht, wenden Sie sich
msgid "This e-mail never contains your password."
msgstr "Diese E-Mail enthält niemals Ihr Passwort."
msgid "Profile"
msgstr "Profil"
msgid "First name"
msgstr "Vorname"
msgid "Last name"
msgstr "Nachname"
msgid "Display name"
msgstr "Anzeigename"
msgid "Telephone"
msgstr "Telefon"
msgid "Mobile"
msgstr "Mobil"
#, python-format
msgid "%(field)s is required."
msgstr "%(field)s ist erforderlich."
#, python-format
msgid "%(field)s must not contain line breaks or control characters."
msgstr "%(field)s darf keine Zeilenumbrüche oder Steuerzeichen enthalten."
#, python-format
msgid "%(field)s must not be longer than %(max)s characters."
msgstr "%(field)s darf nicht länger als %(max)s Zeichen sein."
#, python-format
msgid "%(field)s must be a number that starts with + and the country code, or with 0."
msgstr "%(field)s muss eine Nummer sein, die mit + und der Landesvorwahl oder mit 0 beginnt."
msgid "These details are stored in your account. Changes apply to every service that uses it."
msgstr "Diese Angaben sind in Ihrem Konto gespeichert. Änderungen gelten für jeden Dienst, der es verwendet."
msgid "Your changes were saved."
msgstr "Ihre Änderungen wurden gespeichert."
msgid "Nothing to change."
msgstr "Es gibt nichts zu ändern."
msgid "Your changes were not saved."
msgstr "Ihre Änderungen wurden nicht gespeichert."
msgid "User name"
msgstr "Benutzername"
msgid "E-mail address"
msgstr "E-Mail-Adresse"
msgid "For example +49 171 123456. A number that starts with 0 is read as German."
msgstr "Zum Beispiel +49 171 123456. Eine Nummer, die mit 0 beginnt, gilt als deutsche Nummer."
msgid "Save changes"
msgstr "Änderungen speichern"
msgid "A value was not accepted. Please check your entries."
msgstr "Ein Wert wurde nicht angenommen. Bitte prüfen Sie Ihre Eingaben."
msgid "Your details may have been changed, but the portal could not finish. Please open your profile and check them. If they are wrong, contact the administrator."
msgstr "Ihre Angaben wurden möglicherweise geändert, aber das Portal konnte nicht abschließen. Bitte öffnen Sie Ihr Profil und prüfen Sie sie. Wenn sie falsch sind, wenden Sie sich an die Administration."
msgid "Back to the profile"
msgstr "Zurück zum Profil"
+15
View File
@@ -68,3 +68,18 @@ msgstr "Eine Bestätigung wurde an deine E-Mail-Adresse gesendet."
msgid "Your password may have been changed, but the portal could not finish. Try to sign in with the new password. If that does not work, contact the administrator."
msgstr "Dein Passwort wurde möglicherweise geändert, aber das Portal konnte den Vorgang nicht abschließen. Versuche, dich mit dem neuen Passwort anzumelden. Wenn das nicht klappt, wende dich an die Administration."
msgid "These details are stored in your account. Changes apply to every service that uses it."
msgstr "Diese Angaben sind in deinem Konto gespeichert. Änderungen gelten für jeden Dienst, der es verwendet."
msgid "Your changes were saved."
msgstr "Deine Änderungen wurden gespeichert."
msgid "Your changes were not saved."
msgstr "Deine Änderungen wurden nicht gespeichert."
msgid "A value was not accepted. Please check your entries."
msgstr "Ein Wert wurde nicht angenommen. Bitte prüfe deine Eingaben."
msgid "Your details may have been changed, but the portal could not finish. Please open your profile and check them. If they are wrong, contact the administrator."
msgstr "Deine Angaben wurden möglicherweise geändert, aber das Portal konnte nicht abschließen. Bitte öffne dein Profil und prüfe sie. Wenn sie falsch sind, wende dich an die Administration."
+130
View File
@@ -1,4 +1,8 @@
import re
import unicodedata
from django import forms
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
MAX_PASSWORD_LENGTH = 256 # Active Directory's limit
@@ -33,3 +37,129 @@ class PasswordChangeForm(forms.Form):
elif new and current and new == current:
self.add_error("new_password", _("The new password must be different."))
return data
# -- profile details --------------------------------------------------------------------------
# Form field name -> (label, maximum length (AD's limit), required)
DETAIL_FIELDS = {
"first_name": (_("First name"), 64, True),
"last_name": (_("Last name"), 64, True),
"display_name": (_("Display name"), 256, True),
"telephone": (_("Telephone"), 64, False),
"mobile": (_("Mobile"), 64, False),
}
PHONE_FIELDS = ("telephone", "mobile")
PHONE_MIN_DIGITS = 7
PHONE_MAX_DIGITS = 15
DEFAULT_COUNTRY_CODE = "+49" # a leading 0 is read as Germany
_PHONE_SEPARATORS = re.compile(r"[ \-/.()]")
_PHONE_SHAPE = re.compile(r"\+?[0-9]+")
class InvalidNumber(ValueError):
"""The text cannot be read as a telephone number."""
def normalize_phone(text: str) -> str:
"""The one stored form of a telephone number: `+` and digits only.
Spaces, `-`, `/`, `.` and brackets are removed and a `(0)` after a country code is dropped.
`00` becomes `+`, a single leading `0` becomes `+49`. Anything else raises InvalidNumber.
The result is its own normal form, so normalizing twice changes nothing.
"""
text = text.strip()
if text.startswith(("+", "00")):
text = text.replace("(0)", "")
text = _PHONE_SEPARATORS.sub("", text)
if not _PHONE_SHAPE.fullmatch(text):
raise InvalidNumber(text)
if text.startswith("00"):
text = "+" + text[2:]
elif text.startswith("0"):
text = DEFAULT_COUNTRY_CODE + text[1:]
elif not text.startswith("+"):
raise InvalidNumber(text)
if not PHONE_MIN_DIGITS <= len(text) - 1 <= PHONE_MAX_DIGITS:
raise InvalidNumber(text)
return text
def _has_control_character(text: str) -> bool:
return any(unicodedata.category(ch) in ("Cc", "Zl", "Zp") for ch in text)
def _check_one(name: str, value: str) -> str:
"""The cleaned value of a changed field, or a ValueError carrying the message."""
label, max_length, required = DETAIL_FIELDS[name]
if not value:
if required:
raise ValueError(gettext("%(field)s is required.") % {"field": label})
return ""
if _has_control_character(value):
raise ValueError(
gettext("%(field)s must not contain line breaks or control characters.")
% {"field": label}
)
if len(value) > max_length:
raise ValueError(
gettext("%(field)s must not be longer than %(max)s characters.")
% {"field": label, "max": max_length}
)
if name in PHONE_FIELDS:
try:
return normalize_phone(value)
except InvalidNumber:
raise ValueError(
gettext(
"%(field)s must be a number that starts with + and the country code, or with 0."
)
% {"field": label}
) from None
return value
def check_details(
values: dict[str, str], current: dict[str, str]
) -> tuple[dict[str, str], dict[str, str]]:
"""Check submitted values against what the directory holds now.
Returns (cleaned, errors). A field whose trimmed text equals the directory value is neither
checked nor changed, so one old value in the directory never blocks saving another field.
A number written in another way but equal once normalized is unchanged too.
"""
cleaned: dict[str, str] = {}
errors: dict[str, str] = {}
for name in DETAIL_FIELDS:
value = (values.get(name) or "").strip()
existing = current.get(name) or ""
if value == existing.strip():
cleaned[name] = existing
continue
try:
result = _check_one(name, value)
except ValueError as exc:
errors[name] = str(exc)
continue
cleaned[name] = existing if result == existing.strip() else result
return cleaned, errors
class _TextField(forms.CharField):
"""Trims only: the checks need the directory value and live in `check_details`."""
def run_validators(self, value):
pass
class DetailsForm(forms.Form):
first_name = _TextField(required=False, strip=True)
last_name = _TextField(required=False, strip=True)
display_name = _TextField(required=False, strip=True)
telephone = _TextField(required=False, strip=True)
mobile = _TextField(required=False, strip=True)
def apply_errors(self, errors: dict[str, str]) -> None:
for name, message in errors.items():
self.add_error(name, message)
+149 -3
View File
@@ -1,4 +1,4 @@
"""Password change for the signed-in user (FR-5, FR-6, FR-7; AD-3, AD-4, AD-8).
"""Password change and profile details of the signed-in user (FR-5..FR-9; AD-3, AD-4, AD-8).
The order is fixed: throttle check, policy read, verification of the current password, local
hints, then the directory change, the ended sessions, the audit entry and the queued mail in
@@ -10,7 +10,7 @@ from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import timedelta
from zoneinfo import ZoneInfo
@@ -22,11 +22,20 @@ from django.utils.translation import ngettext
from portal.accounts import throttle
from portal.accounts.sessions import SessionUser, end_sessions
from portal.adapters.directory import DirectoryUnavailable, PasswordPolicy, PasswordRejected
from portal.adapters.directory import (
DirectoryUnavailable,
DirectoryUser,
Forbidden,
PasswordPolicy,
PasswordRejected,
ValidationFailed,
)
from portal.adapters.registry import get_directory
from portal.audit import services as audit
from portal.mailer import services as mailer
from .forms import DETAIL_FIELDS, check_details
logger = logging.getLogger(__name__)
ENDPOINT = "password-change"
@@ -309,3 +318,140 @@ def _queue_mail(account) -> bool:
{"name": name or account.sam, "changed_at": now, "zone_name": settings.DISPLAY_TIME_ZONE},
)
return True
# -- profile details (FR-9) -------------------------------------------------------------------
# Form field -> (directory attribute, DirectoryUser field). Only these five are ever written.
DETAIL_ATTRIBUTES = {
"first_name": ("givenName", "given_name"),
"last_name": ("sn", "surname"),
"display_name": ("displayName", "display_name"),
"telephone": ("telephoneNumber", "telephone"),
"mobile": ("mobile", "mobile"),
}
class DetailsInvalid(Exception):
"""Values were refused before anything was written; `errors` maps a field to its message."""
def __init__(self, errors: dict[str, str], details: Details) -> None:
self.errors = errors
self.details = details # what the one directory read held, for the page
super().__init__("The details are not valid")
class DetailsNotAccepted(Exception):
"""The directory refused a value."""
def __init__(self, details: Details) -> None:
self.details = details
super().__init__("The directory refused a value")
@dataclass(frozen=True)
class Details:
username: str
mail: str
values: dict[str, str]
@dataclass(frozen=True)
class DetailsUpdated:
changed: tuple[str, ...] # form field names, in page order
account: DirectoryUser # the user as the directory now holds them
def _values_of(account: DirectoryUser) -> dict[str, str]:
return {name: getattr(account, attr) or "" for name, (_attr, attr) in DETAIL_ATTRIBUTES.items()}
def _read(user: SessionUser) -> DirectoryUser:
account = get_directory().find_user(user.sam)
if account is None:
raise DirectoryUnavailable("The account could not be read")
return account
def load_details(user: SessionUser) -> Details:
"""What the directory holds about the signed-in user. Raises DirectoryUnavailable."""
account = _read(user)
return Details(username=account.sam, mail=account.mail or "", values=_values_of(account))
def _record_details_failure(user: SessionUser, ip: str, reason: str, **detail) -> None:
audit.record_failure(
user.key, "profile.update_failed", ip=ip, detail={"reason": reason, **detail}
)
def update_details(user: SessionUser, values: dict[str, str], *, ip: str) -> DetailsUpdated:
"""Save the changed details of the signed-in user.
Reads the directory afresh, checks the values that differ and writes only those, with the
audit entry in the same transaction (AD-4). Raises DetailsInvalid, DetailsNotAccepted,
DirectoryUnavailable or OutcomeUnknown. Must be called outside an open transaction.
"""
directory = get_directory()
try:
account = _read(user)
except DirectoryUnavailable:
_record_details_failure(user, ip, "unavailable")
raise
current = _values_of(account)
read = Details(username=account.sam, mail=account.mail or "", values=current)
cleaned, errors = check_details(values, current)
if errors:
raise DetailsInvalid(errors, read)
changed = tuple(name for name in DETAIL_FIELDS if cleaned[name] != current[name])
if not changed:
return DetailsUpdated((), account)
changes = {DETAIL_ATTRIBUTES[name][0]: cleaned[name] for name in changed}
updated = replace(account, **{DETAIL_ATTRIBUTES[name][1]: cleaned[name] for name in changed})
changed_in_directory = False
step = "directory"
try:
with transaction.atomic():
directory.update_attributes(user.sam, changes)
changed_in_directory = True
step = "audit"
audit.record(user.key, "profile.updated", ip=ip, detail={"fields": list(changed)})
step = "commit"
except ValidationFailed as exc:
_record_details_failure(user, ip, "refused")
raise DetailsNotAccepted(read) from exc
except DirectoryUnavailable:
_record_details_failure(user, ip, "unavailable")
raise
except Forbidden as exc: # the service account lacks the right: the user sees "not possible"
logger.error(
"The directory refused a profile write: user=%s ip=%s fields=%s",
user.key,
ip,
",".join(changed),
)
_record_details_failure(user, ip, "forbidden")
raise DirectoryUnavailable("The directory refused the write") from exc
except Exception as exc:
if changed_in_directory:
# AD-4: the directory changed but the portal's part did not commit.
logger.error(
"Profile details changed in the directory but the portal did not record them: "
"user=%s ip=%s time=%s fields=%s step=%s error=%s",
user.key,
ip,
timezone.now().isoformat(),
",".join(changed),
step,
type(exc).__name__,
)
try:
_record_details_failure(user, ip, "unclear", step=step, fields=list(changed))
except Exception:
logger.error("The failure entry for %s could not be written", user.key)
raise OutcomeUnknown("The details were changed but not recorded") from exc
_record_details_failure(user, ip, "error")
raise
return DetailsUpdated(changed, updated)
+1
View File
@@ -3,6 +3,7 @@ from django.urls import path
from . import views
urlpatterns = [
path("profile", views.profile, name="profile"),
path("password", views.password, name="password"),
path("password/done", views.password_done, name="password-done"),
]
+53 -1
View File
@@ -10,12 +10,13 @@ from django.views.decorators.debug import sensitive_post_parameters
from django.views.decorators.http import require_http_methods
from portal.accounts.decorators import login_required
from portal.accounts.identity import remember
from portal.accounts.throttle import Throttled
from portal.adapters.directory import DirectoryUnavailable
from portal.adapters.registry import get_directory
from . import services
from .forms import PasswordChangeForm
from .forms import DETAIL_FIELDS, DetailsForm, PasswordChangeForm
logger = logging.getLogger(__name__)
@@ -107,3 +108,54 @@ def password_done(request):
if flag is None: # opened directly: nothing to report
return redirect("password")
return render(request, "profile/password_done.html", {"mail": flag["mail"]})
# -- profile details (FR-9) -------------------------------------------------------------------
NOTICE_FLAG = "profile_notice" # "saved" or "unchanged", shown once after the redirect
def _details_page(request, form, details, *, status=200, notice=None):
context = {
"form": form,
"details": details,
"notice": notice,
"fields": [
{"field": form[name], "label": label, "max_length": max_length}
for name, (label, max_length, _required) in DETAIL_FIELDS.items()
],
}
return render(request, "profile/details.html", context, status=status)
@login_required
@require_http_methods(["GET", "POST"])
def profile(request):
user = request.portal_user.user # the session user only: nothing is read from the request
if request.method == "GET":
try:
details = services.load_details(user)
except DirectoryUnavailable:
return _unavailable(request)
notice = request.session.pop(NOTICE_FLAG, None)
return _details_page(request, DetailsForm(initial=details.values), details, notice=notice)
form = DetailsForm(request.POST)
form.is_valid() # trims; the checks need the directory value and run in the service
try:
result = services.update_details(user, form.cleaned_data, ip=request.client_ip)
except services.DetailsInvalid as exc:
form.apply_errors(exc.errors)
return _details_page(request, form, exc.details, status=400)
except services.DetailsNotAccepted as exc:
form.add_error(None, _("A value was not accepted. Please check your entries."))
return _details_page(request, form, exc.details, status=400)
except DirectoryUnavailable:
return _unavailable(request)
except services.OutcomeUnknown:
return render(request, "profile/details_unknown.html", status=500)
if result.changed:
remember(request, result.account) # this session shows the new name at once
request.session[NOTICE_FLAG] = "saved" if result.changed else "unchanged"
return redirect("profile")
+9 -1
View File
@@ -22,7 +22,15 @@ class Group:
MENU = (
Group(None, None, (Entry(_("Home"), "home"), Entry(_("Change password"), "password"))),
Group(
None,
None,
(
Entry(_("Home"), "home"),
Entry(_("Profile"), "profile"),
Entry(_("Change password"), "password"),
),
),
Group(
_("Publishing"),
"publisher",
+14
View File
@@ -578,3 +578,17 @@ main button.primary:disabled {
animation-duration: 3s;
}
}
/* Read-only label/value rows on the profile page; they stack on narrow screens. */
.details {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.5rem 1.5rem;
margin-block: 0 1rem;
}
.details dt { font-weight: 600; }
.details dd { margin: 0; overflow-wrap: anywhere; }
@media (max-width: 720px) {
.details { grid-template-columns: 1fr; gap: 0; }
.details dd { margin-bottom: 0.75rem; }
}
@@ -0,0 +1,7 @@
{% load i18n %}
<div class="field{% if field.errors %} has-error{% endif %}">
<label for="{{ field.id_for_label }}">{{ label }}</label>
<input type="{{ input_type|default:'text' }}" name="{{ field.html_name }}" id="{{ field.id_for_label }}" value="{{ field.value|default_if_none:'' }}" autocomplete="{{ autocomplete }}" maxlength="{{ max_length }}"{% if required %} required{% endif %}{% if field.errors %} aria-invalid="true"{% endif %} aria-describedby="{{ field.id_for_label }}-hint{% if field.errors %} {{ field.id_for_label }}-error{% endif %}">
{% if hint %}<p class="hint" id="{{ field.id_for_label }}-hint">{{ hint }}</p>{% else %}<span id="{{ field.id_for_label }}-hint"></span>{% endif %}
{% if field.errors %}<div id="{{ field.id_for_label }}-error">{% for error in field.errors %}<p class="error">{{ error }}</p>{% endfor %}</div>{% endif %}
</div>
+54
View File
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Profile" %}{% endblock %}
{% block content %}
<h1>{% trans "Profile" %}</h1>
<p class="lead">{% trans "These details are stored in your account. Changes apply to every service that uses it." %}</p>
{% if notice == "saved" %}
<div class="notice success" role="status">
<p>{% trans "Your changes were saved." %}</p>
</div>
{% elif notice == "unchanged" %}
<div class="notice" role="status">
<p>{% trans "Nothing to change." %}</p>
</div>
{% endif %}
{% if form.errors %}
<div class="error-summary" role="alert" tabindex="-1" data-focus-on-load>
<p><strong>{% trans "Your changes were not saved." %}</strong></p>
<ul>
{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}
{% for field in form %}{% for error in field.errors %}
<li><a href="#{{ field.id_for_label }}">{{ error }}</a></li>
{% endfor %}{% endfor %}
</ul>
</div>
{% endif %}
{% if details %}
<dl class="card details">
<dt>{% trans "User name" %}</dt>
<dd>{{ details.username }}</dd>
<dt>{% trans "E-mail address" %}</dt>
<dd>{% if details.mail %}{{ details.mail }}{% else %}&ndash;{% endif %}</dd>
</dl>
{% endif %}
<form method="post" action="{% url 'profile' %}" class="card" data-busy-on-submit novalidate>
{% csrf_token %}
{% trans "For example +49 171 123456. A number that starts with 0 is read as German." as phone_hint %}
{% for item in fields %}
{% if item.field.name == "first_name" %}
{% include "profile/_text_field.html" with field=item.field label=item.label max_length=item.max_length autocomplete="given-name" required=True %}
{% elif item.field.name == "last_name" %}
{% include "profile/_text_field.html" with field=item.field label=item.label max_length=item.max_length autocomplete="family-name" required=True %}
{% elif item.field.name == "display_name" %}
{% include "profile/_text_field.html" with field=item.field label=item.label max_length=item.max_length autocomplete="name" required=True %}
{% else %}
{% include "profile/_text_field.html" with field=item.field label=item.label max_length=item.max_length autocomplete="tel" input_type="tel" hint=phone_hint %}
{% endif %}
{% endfor %}
<p class="actions">
<button type="submit" class="primary"><span class="spinner" aria-hidden="true"></span>{% trans "Save changes" %}</button>
<a class="button" href="{% url 'home' %}">{% trans "Cancel" %}</a>
</p>
</form>
{% endblock %}
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Result unclear" %}{% endblock %}
{% block content %}
<h1>{% trans "Result unclear" %}</h1>
<div class="notice" role="alert">
<p>{% trans "Your details may have been changed, but the portal could not finish. Please open your profile and check them. If they are wrong, contact the administrator." %}</p>
</div>
<p><a class="button" href="{% url 'profile' %}">{% trans "Back to the profile" %}</a></p>
{% endblock %}
+411
View File
@@ -0,0 +1,411 @@
"""Profile edit: the page, the phone format, the diff against AD, audit and errors (FR-9)."""
import logging
import re
import pytest
from django.test import Client
from portal.adapters.directory import DirectoryUnavailable, Forbidden, ValidationFailed
from portal.audit import services as audit_services
from portal.audit.models import AuditEntry
from portal.profile.forms import InvalidNumber, normalize_phone
from portal.tests.helpers import sign_in
pytestmark = pytest.mark.django_db(transaction=True)
VALID = {
"first_name": "Alice",
"last_name": "Anders",
"display_name": "Alice Anders",
"telephone": "+49301234567",
"mobile": "intern 23",
}
@pytest.fixture
def people(adapters):
d = adapters.directory
d.add_user(
"alice",
given_name="Alice",
surname="Anders",
display_name="Alice Anders",
mail="alice@example.test",
telephone="+49301234567",
mobile="intern 23", # an old value that is not a number
)
d.add_user("bob", given_name="Bob", surname="Brown", display_name="Bob Brown")
return d
def signed_in(adapters, name="alice"):
client, _ = sign_in(adapters, name)
return client
def save(client, **changes):
return client.post("/profile", {**VALID, **changes})
def profile_entries():
return AuditEntry.objects.filter(action__startswith="profile.")
def audit_actions():
return list(profile_entries().values_list("action", flat=True))
def stored(adapters, sam="alice"):
return adapters.directory.find_user(sam)
class Spy:
def __init__(self, directory):
self.writes = []
original = directory.update_attributes
def wrapper(sam, changes):
self.writes.append((sam, dict(changes)))
return original(sam, changes)
directory.update_attributes = wrapper
# -- the page ------------------------------------------------------------------------------
def test_the_page_shows_the_ad_values_and_plain_text_for_username_and_mail(people, adapters):
html = signed_in(adapters).get("/profile").content.decode()
for value in ("Alice", "Anders", "Alice Anders", "+49301234567", "intern 23"):
assert f'value="{value}"' in html
assert "alice@example.test" in html
assert 'name="username"' not in html and 'name="mail"' not in html
assert re.search(r'<a href="/profile" class="current" aria-current="page">', html)
def test_the_page_speaks_german_in_both_registers(people, adapters):
client = signed_in(adapters)
client.cookies["pm_lang"] = "de"
assert "Diese Angaben sind in Ihrem Konto" in client.get("/profile").content.decode()
client.cookies["pm_form"] = "du"
assert "in deinem Konto" in client.get("/profile").content.decode()
def test_the_page_is_unavailable_when_the_directory_is_down(people, adapters):
client = signed_in(adapters)
adapters.directory.unavailable = True
response = client.get("/profile")
assert response.status_code == 503 and b'name="first_name"' not in response.content
def test_access_rules(people, adapters):
assert Client().get("/profile").status_code == 302
assert Client().post("/profile", VALID).status_code == 302
assert signed_in(adapters).put("/profile").status_code == 405
client = signed_in(adapters)
strict = Client(enforce_csrf_checks=True)
strict.cookies = client.cookies
assert strict.post("/profile", {**VALID, "first_name": "Eve"}).status_code == 403
assert stored(adapters).given_name == "Alice"
# -- saving ----------------------------------------------------------------------------------
def test_only_the_changed_fields_are_written_and_audited_without_values(people, adapters):
spy = Spy(adapters.directory)
client = signed_in(adapters)
response = save(client, first_name="Alicia", mobile="0171 / 123456")
assert response.status_code == 302 and response["Location"] == "/profile"
assert spy.writes == [("alice", {"givenName": "Alicia", "mobile": "+49171123456"})]
account = stored(adapters)
assert (account.given_name, account.mobile) == ("Alicia", "+49171123456")
assert (account.surname, account.telephone, account.mail) == (
"Anders",
"+49301234567",
"alice@example.test",
)
entry = AuditEntry.objects.get(action="profile.updated")
assert entry.actor == "alice" and entry.ip == "127.0.0.1"
assert entry.detail == {"fields": ["first_name", "mobile"]}
assert profile_entries().count() == 1
def test_the_saved_notice_shows_once_with_the_stored_number_form(people, adapters):
client = signed_in(adapters)
response = save(client, telephone="0049 (0) 30 1234568")
page = client.get(response["Location"]).content.decode()
assert "Your changes were saved." in page and 'value="+49301234568"' in page
assert "Your changes were saved." not in client.get("/profile").content.decode()
def test_nothing_changed_makes_no_write_and_no_audit(people, adapters):
spy = Spy(adapters.directory)
client = signed_in(adapters)
response = save(client)
assert response.status_code == 302 and spy.writes == [] and profile_entries().count() == 0
assert "Nothing to change." in client.get("/profile").content.decode()
def test_a_number_equal_once_normalized_is_not_a_change(people, adapters):
spy = Spy(adapters.directory)
save(signed_in(adapters), telephone="+49 30 1234567")
assert spy.writes == [] and profile_entries().count() == 0
def test_an_empty_number_clears_the_attribute(people, adapters):
spy = Spy(adapters.directory)
save(signed_in(adapters), telephone="")
assert spy.writes == [("alice", {"telephoneNumber": ""})]
assert stored(adapters).telephone == ""
def test_an_old_value_that_is_not_a_number_never_blocks_another_field(people, adapters):
save(signed_in(adapters), last_name="Ander")
account = stored(adapters)
assert account.surname == "Ander" and account.mobile == "intern 23"
def test_old_values_that_break_the_rules_are_left_alone_but_changed_ones_are_checked(
people, adapters
):
long_name = "x" * 100
adapters.directory.add_user("carol", given_name=long_name, surname="", display_name="Carol")
client = signed_in(adapters, "carol")
spy = Spy(adapters.directory)
old = {
"first_name": long_name,
"last_name": "",
"display_name": "Carol",
"telephone": "",
"mobile": "",
}
assert client.post("/profile", {**old, "mobile": "5"}).status_code == 400
assert client.post("/profile", {**old, "display_name": "Caro"}).status_code == 302
assert spy.writes == [("carol", {"displayName": "Caro"})]
def test_the_session_name_follows_the_saved_display_name(people, adapters):
client = signed_in(adapters)
assert "Alice Anders" in client.get("/").content.decode()
save(client, display_name="Ally")
assert "Ally" in client.get("/profile").content.decode()
assert "Alice Anders" not in client.get("/").content.decode()
def test_the_save_compares_with_a_fresh_read_not_with_the_page(people, adapters):
spy = Spy(adapters.directory)
client = signed_in(adapters)
client.get("/profile")
adapters.directory.update_attributes("alice", {"sn": "Elsewhere"})
save(client, display_name="Ally", last_name="Elsewhere") # same as AD now: not written
assert spy.writes[1:] == [("alice", {"displayName": "Ally"})]
# -- phone numbers ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("text", "result"),
[
("0171 / 123456", "+49171123456"),
("0171123456", "+49171123456"),
("+49 171 123456", "+49171123456"),
("+49 (0) 171-123456", "+49171123456"),
("0049 171 123456", "+49171123456"),
("0049 (0) 171 123456", "+49171123456"),
("+43 660 1234567", "+436601234567"),
("0043 660 1234567", "+436601234567"),
("(0171) 123.456", "+49171123456"),
("+49171123456", "+49171123456"),
],
)
def test_phone_inputs_and_their_stored_form(text, result):
assert normalize_phone(text) == result
assert normalize_phone(result) == result
@pytest.mark.parametrize(
"text",
[
"171123456",
"abc",
"0171 abc",
"+49 171 12+3456",
"+49 1711 23456 7890 1234",
"0123",
"+49 12",
"++49171123456",
"intern 23",
"",
"*)(uid=*",
],
)
def test_phone_inputs_that_are_refused(text):
with pytest.raises(InvalidNumber):
normalize_phone(text)
@pytest.mark.parametrize("field", ["telephone", "mobile"])
@pytest.mark.parametrize("text", ["171123456", "0171 abc", "+49 12", "+49 171 12+3456"])
def test_a_bad_number_is_refused_under_its_field(people, adapters, field, text):
spy = Spy(adapters.directory)
response = save(signed_in(adapters), **{field: text})
html = response.content.decode()
assert response.status_code == 400 and spy.writes == []
assert "must be a number that starts with + and the country code, or with 0" in html
assert 'class="error"' in html and profile_entries().count() == 0
# -- invalid values ---------------------------------------------------------------------------
@pytest.mark.parametrize("field", ["first_name", "last_name", "display_name"])
def test_a_required_name_cannot_be_empty(people, adapters, field):
spy = Spy(adapters.directory)
response = save(signed_in(adapters), **{field: " "})
assert response.status_code == 400 and spy.writes == [] and profile_entries().count() == 0
assert "is required." in response.content.decode()
@pytest.mark.parametrize(
("field", "value"),
[
("first_name", "x" * 65),
("last_name", "x" * 65),
("display_name", "x" * 257),
("first_name", "A\nB"),
("last_name", "A\tB"),
("display_name", "A\x00B"),
],
)
def test_too_long_or_control_characters_are_refused_and_other_input_stays(
people, adapters, field, value
):
spy = Spy(adapters.directory)
response = save(signed_in(adapters), mobile="0171 5", **{field: value})
html = response.content.decode()
assert response.status_code == 400 and spy.writes == [] and profile_entries().count() == 0
assert "Your changes were not saved." in html
assert 'value="0171 5"' in html # what else was typed stays in the form
assert f'<a href="#id_{field}">' in html
def test_the_limits_are_ads_limits(people, adapters):
save(signed_in(adapters), first_name="x" * 64, display_name="y" * 256)
account = stored(adapters)
assert len(account.given_name) == 64 and len(account.display_name) == 256
def test_values_are_trimmed(people, adapters):
save(signed_in(adapters), first_name=" Alicia ")
assert stored(adapters).given_name == "Alicia"
@pytest.mark.parametrize("text", ["*)(uid=*", "\\00", "CN=x,DC=y", "a)(|(cn=*"])
def test_injection_strings_are_plain_text_and_touch_nothing_else(people, adapters, text):
spy = Spy(adapters.directory)
before_bob = stored(adapters, "bob")
assert save(signed_in(adapters), display_name=text).status_code == 302
assert spy.writes == [("alice", {"displayName": text})]
assert stored(adapters).display_name == text
assert stored(adapters, "bob") == before_bob
# -- who is edited -----------------------------------------------------------------------------
def test_a_posted_username_is_ignored_and_another_user_is_untouched(people, adapters):
before_bob = stored(adapters, "bob")
response = save(signed_in(adapters), first_name="Alicia", username="bob", sam="bob", dn="x")
assert response.status_code == 302
assert stored(adapters).given_name == "Alicia" and stored(adapters, "bob") == before_bob
assert AuditEntry.objects.get(action="profile.updated").actor == "alice"
# -- failures ------------------------------------------------------------------------------------
def test_ad_refusing_a_value_changes_nothing_and_is_audited_as_failure(people, adapters):
def refuse(sam, changes):
raise ValidationFailed("constraint")
adapters.directory.update_attributes = refuse
response = save(signed_in(adapters), first_name="Alicia")
html = response.content.decode()
assert response.status_code == 400
assert "A value was not accepted. Please check your entries." in html
assert "constraint" not in html and 'value="Alicia"' in html
assert stored(adapters).given_name == "Alice"
assert audit_actions() == ["profile.update_failed"]
assert profile_entries().get().detail == {"reason": "refused"}
def test_directory_down_on_save_is_503_and_audited(people, adapters):
client = signed_in(adapters)
adapters.directory.unavailable = True
response = save(client, first_name="Alicia")
assert response.status_code == 503
assert "This is not possible right now. Please try again later." in response.content.decode()
adapters.directory.unavailable = False
assert stored(adapters).given_name == "Alice"
assert audit_actions() == ["profile.update_failed"]
assert profile_entries().get().detail == {"reason": "unavailable"}
def test_directory_failing_during_the_write_rolls_back_and_audits(people, adapters):
def down(sam, changes):
raise DirectoryUnavailable("gone")
adapters.directory.update_attributes = down
assert save(signed_in(adapters), first_name="Alicia").status_code == 503
assert audit_actions() == ["profile.update_failed"]
def test_a_failure_after_the_ad_call_is_logged_and_the_user_told_it_is_unclear(
people, adapters, monkeypatch, caplog
):
real_record = audit_services.record
def broken(*args, **kwargs):
raise RuntimeError("database gone: Alicia")
client = signed_in(adapters)
monkeypatch.setattr(audit_services, "record", broken)
with caplog.at_level(logging.ERROR):
response = save(client, first_name="Alicia", mobile="0171 5555555")
monkeypatch.setattr(audit_services, "record", real_record)
assert response.status_code == 500 and "may have been changed" in response.content.decode()
assert stored(adapters).given_name == "Alicia" # AD did change
assert audit_actions() == ["profile.update_failed"] # no "updated" entry survived
log = next(r.getMessage() for r in caplog.records if r.levelno == logging.ERROR)
assert "user=alice" in log and "fields=first_name,mobile" in log and "step=audit" in log
assert "Alicia" not in log and "5555555" not in log
def test_a_missing_write_right_is_the_not_possible_page_and_a_failure_entry(
people, adapters, caplog
):
def forbidden(sam, changes):
raise Forbidden("insufficient access: Alicia")
client = signed_in(adapters)
adapters.directory.update_attributes = forbidden
with caplog.at_level(logging.ERROR):
response = save(client, first_name="Alicia")
assert response.status_code == 503
assert "This is not possible right now." in response.content.decode()
assert audit_actions() == ["profile.update_failed"]
assert profile_entries().get().detail == {"reason": "forbidden"}
assert not any("Alicia" in r.getMessage() for r in caplog.records)
def test_an_invalid_save_reads_the_directory_once(people, adapters):
client = signed_in(adapters)
reads = []
original = adapters.directory.find_user
adapters.directory.find_user = lambda sam: reads.append(sam) or original(sam)
response = save(client, first_name=" ")
assert response.status_code == 400 and len(reads) == 1
assert "alice@example.test" in response.content.decode()