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
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
ae4b30ec22
commit
3fd8f27127
@@ -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.
|
||||
|
||||
+132
@@ -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.
|
||||
Reference in New Issue
Block a user