Files
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

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
quick
0
{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

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 -- add DetailsForm (five CharFields, 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.