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
12 KiB
title, type, ticket, epic, created, status, baseline_revision, route, route_source, risk, review, review_source, lenses_ran, review_loop_iteration, context
| title | type | ticket | epic | created | status | baseline_revision | route | route_source | risk | review | review_source | lenses_ran | review_loop_iteration | context | |||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Profile edit | feature | 2 | epic-account-self-service | 2026-10-19 | built | ae4b30ec22 |
full | auto | medium | quick | pinned |
|
0 |
|
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 |
Code Map
portal/profile/forms.py-- addDetailsForm(fiveCharFields,strip=True,max_lengthper field, the three names required, a control-character check,normalize_phonefor the two number fields).PasswordChangeFormstays.portal/profile/services.py-- addload_details(user)(directory read to initial values) andupdate_details(user, values, *, ip)(diff against a fresh read,transaction.atomicwithdirectory.update_attributesandaudit.record, failure entry after rollback). Reuse_record_failurestyle and the typed errorsDirectoryUnavailable,ValidationFailed.portal/profile/views.py,urls.py-- add theprofileview (GET, POST) at/profile, nameprofile;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.htmlis password-only:type="password").portal/shell/menu.py-- addEntry(_("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_attributesalready exists in the interface,FakeDirectoryandLdapDirectory(MOD_REPLACE, empty value clears, allow-list enforced, NUL refused). Reuse unchanged.portal/locale/de,de_DU-- German text;portal/tests/test_translations.pychecks coverage.- Do not change: the password flow,
accounts.throttle, the adapter.
Tasks & Acceptance
Execution:
portal/profile/forms.py--DetailsFormandnormalize_phone-- FR-9portal/profile/services.py--load_details,update_details-- AD-2, AD-3, AD-4portal/profile/views.py,urls.py,portal/templates/profile/*,portal/shell/menu.py-- page, notice, error mapping to messages and status codes, menu entry -- AD-17portal/locale/de,de_DU-- German text for every new stringportal/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/tests423 passed, 6 skipped;ruff checkclean. The done checkpoint against the test OU is not run. - The checks (required, control characters, length, phone) run in
forms.check_details, called fromservices.update_details, not inDetailsForm: they need the directory value to leave an unchanged old value alone.DetailsFormonly 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_failedwith reasonunavailable,refused,errororunclear.
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.