13 KiB
QA: Profile page, display name and the account menu
Implementation note for coder (informational, not a QA assertion)
There is no Go change and no HTTP-level Go section: PATCH /v1/user/me, GET /v1/auth/me and POST /v1/auth/password-reset are already covered (features/user_display_name.qa.md, features/get_current_user.qa.md, features/request_password_reset.qa.md).
Amended specs (their Vitest tests must be updated in the same change): dashboard_shell_branding.feature (UserMenu label, initials, groups and the collapsed aria-label), home.feature (greeting name), plus session.spec.ts, useApi.spec.ts (getMe maps email and display_name, absent means ""), UserMenu.spec.ts, DashboardShell.spec.ts and the HomeView spec.
The Saved toast uses useToast().add({ id: 'display-name-saved', color: 'success', icon: 'i-lucide-circle-check', title: 'Display name saved', description: 'You now appear as {name} across RenewCMS and in emails.' }). The 429 countdown reuses useRetryAfter; the 300 s fallback
applies when ApiError.retryAfter is undefined or 0.
Automated (Vitest + Vue Test Utils, task test:web)
Mock useApi's request and getMe per case; no real backend. Mount ProfilePage inside
DashboardShell with a resolved verified session (id 1, username "marta", email
"marta@renew.example", displayName "") and a test router at /profile. Use fake timers only for the
countdown. No test freezes the system date; the Home greeting cases mock the hour as
home.qa.md does and assert the name part only. Run the layout cases at a desktop and a
below-lg width (mock matchMedia).
- Route guards, Examples-driven - verified renders; unverified redirects to /register/pending; 401 redirects to /login?redirect=/profile&reason=expired.
- Default - breadcrumb "Profile", title, subtitle, empty field with placeholder, "0 / 60", footer "You appear as “marta”, your username.", Save disabled, Username and Email inputs disabled with their values, Password text with the email, "Send reset link", no note; no nav item has aria-current.
- Loading - unresolved GET /v1/auth/me; assert three skeleton cards, aria-busy, no AppBoot; resolve and assert Default.
- From the menu - navigate from /home via "Profile"; assert no skeleton and no second GET /v1/auth/me.
- Editing - type "Marta Alves"; assert "11 / 60", "Unsaved changes", Save enabled, no request.
- Counter, Examples-driven - each table row, including the 60/61 emoji rows and "Cafe" + U+0301 (5); assert count, color class and Save state.
- Too long - the 62-character value; assert "62 / 60", the error with circle-alert, "Unsaved changes", Save disabled, Enter sends nothing; shorten and assert the error clears.
- Dirty rules, Examples-driven - each row.
- Enter saves - assert one PATCH with body
{"display_name":"Marta Alves"}. - Saving - unresolved PATCH with a padded value; assert the trimmed body, disabled and dimmed field, "Saving…", and that Enter and a second click send nothing.
- Saved - resolve 200; assert session.displayName, chip "Marta Alves" with "MA", field value, "11 / 60", footer, Save disabled, the toast arguments, and no leave dialog after navigating.
- Server value wins - resolve with a trimmed display_name; assert all consumers use it.
- Clearing - saved "Marta Alves", empty the field, save; assert body
{"display_name":""}, chip "marta", footer, and the "Display name removed" toast. - Save failed, Examples-driven -
ApiError(500)andNetworkError; assert the alert copy, the kept value, the "Unsaved changes" footer, Save enabled, the session unchanged and no toast; save again and assert the alert clears during the request and PATCH is sent again. - 400, Examples-driven -
ApiError(400)with the display-name text gives the field error; with another body gives the Save failed alert. - Leave guard - dirty draft, navigate to /users; assert ConfirmDialog props (title, text, "Keep editing" as confirm and "Discard changes" as cancel, neutral tone); Keep editing stays; Discard leaves and leaves the session name unchanged. Also assert: clean draft leaves with no dialog; a 401 during PATCH redirects with no dialog (authRedirectPending); Log out while dirty shows the dialog.
- Menu, desktop - click the chip; assert aria-haspopup, aria-expanded, title "Account", the sand class, the header name and email, the item order (Profile, System, Light, Dark, Log out) with the two separators, and Profile not highlighted.
- Menu, mobile - below lg, open the drawer and activate the account row; assert the drawer closed, the sheet with its grab handle, the 36 px avatar header and the same item order.
- Closing - Escape and an outside click; assert closed, aria-expanded false, focus on the chip; ArrowDown and ArrowUp move between items.
- Menu items - Profile navigates and closes; on /profile it is highlighted, closes with no navigation; Log out calls POST /v1/auth/logout, clears the session and goes to /login.
- Chip fill - sand on /profile with the menu closed; not sand on /home.
- Theme - activating "Dark" sets
useColorMode().preference, closes the menu, and reopening shows "Dark" checked. - Initials, Examples-driven - each row, including "Élodie Martin" giving "ÉM", a single word giving two letters, and username "m.alves" giving "M." (first two characters).
- Collapsed rail - avatar only, aria-label "Marta Alves — account menu", same menu.
- Home greeting, Examples-driven - no display name gives "marta"; "Marta Alves" gives "Marta Alves"; then save on /profile and navigate to /home and assert the new name. Do not assert the greeting word or the hour.
- Reset: sending - unresolved POST; assert body
{"email":"marta@renew.example"}, "Sending…", disabled, no note. - Reset: sent and Send again - 200 gives "Send again" and the mail-check note with the exact copy; "Send again" hides the note during the request, sends the same body, and on 200 shows it again; remounting resets to idle.
- Reset: 429, Examples-driven - reject with
ApiError(429, <retryAfter>); assert the warning note copy, disabled button reading "Send reset link"; advance fake timers by the table's seconds minus 1 and assert still disabled, then by 1 and assert enabled with the note gone. Rows: 42, 300, undefined (300), 0 (300). - Reset: failed, Examples-driven -
ApiError(500)andNetworkError; assert the error note copy, enabled button, and that a retry resolving 200 gives the sent note. - Users list - mount UsersPage with session display name "Marta Alves" and the users_screen Default items where marta and ines carry display names; assert the marta row shows "Marta Alves" then "marta" and the "you" tag, ines shows "Inès Duarte" then "ines", jonas shows alone, and the chip reads "Marta Alves".
Visual (task visual:check -- profile, and -- home; desktop and mobile, light and dark)
Reference: docs/design/screens/profile.md, board group "Profile" in docs/design/source/RenewCMS Screens Back-office.dc.html, exact values in docs/design/source/AppFrame.dc.html. Add each
state's setup to web/visual/states.ts with the board's own sample data. The gate is hard: every
capture must be within 10 %. Base session mock: GET /v1/auth/me 200 with id 1, username
"marta", email "marta@renew.example", is_verified true, display_name "" (state-specific
overrides below).
- Account menu: /home with
now: boardNowand the home Default fixtures, session as above (id 1, "marta", "marta@renew.example", display_name ""), menu opened by clicking the chip (on mobile: open the drawer, then the account row). Name "marta", initials "MA", email "marta@renew.example". The board now draws System, Light and Dark, with Light checked in its sample. Theexceptionblock is removed from web/visual/states.ts, so the default 10 % gate applies.task visual:check -- profile "Account menu"must PASS on all four captures (desktop and mobile, light and dark). If the only remaining diff is the board's Profile highlight on Home (the app highlights Profile only on /profile), do NOT re-add an exception and do NOT highlight Profile on Home. Stop and ask the user. - Manual: item heights measured 32 px (desktop popover) and 48 px (mobile sheet); arrow keys move between items; Escape and outside click close; choosing a theme keeps the menu open.
- Loading: /profile with GET /v1/auth/me hanging; three skeleton cards, 16 px apart.
- Default, no display name: empty field, "0 / 60", the username footer, Save at 40 %.
- Editing: type "Marta Alves"; field focused; "11 / 60"; "Unsaved changes"; Save enabled.
- Too long: type "Marta Alves, Head of Renovations, Brussels and Wallonia office"; "62 / 60" red; error "Use 60 characters or fewer."; Save at 40 %.
- Saving: type "Marta Alves", press Save with PATCH hanging; disabled dimmed field, "Saving…".
- Saved: PATCH 200 with display_name "Marta Alves"; chip "Marta Alves" "MA"; toast "Display name saved" / "You now appear as Marta Alves across RenewCMS and in emails."
- Save failed: PATCH 500 after typing "Marta Alves"; the error alert in the card; "Unsaved changes".
- Reset: sending: session display_name "Marta Alves"; POST /v1/auth/password-reset hanging; "Sending…".
- Reset: sent: same session; POST 200; "Send again" and the "Reset link sent" note.
- Reset: too many requests (429): POST 429 with header
Retry-After: 300; disabled button at 40 % and the "Too many requests" note. - Reset: failed: POST 500; the "Couldn't send the link" note.
- Display names in the Users list: /users, session display_name "Marta Alves"; GET /v1/user/ with the five board rows (sam.oliveira@renew.example pending 27 Sep 2026, p.huber@gmail.com expired 3 Aug 2026, ines "Inès Duarte" 18 Jun 2026, jonas 2 Apr 2026, marta "Marta Alves" 12 Mar 2026 with "you"). Clock-sensitive rendering is skipped per repo rules.
task visual:check -- homestill PASSes for Default, Loading, Empty and Error. The session fixture has display_name "", so the greeting name is "marta". Extend the fixture withemailanddisplay_name(it must tolerate them being absent).- Layout from the board frame, not components.md: content column 720 px max; cards separated by 20 px; the footer strip has the status text left and Save right (32 px high on desktop, 44 px on mobile); the account inputs are two columns on desktop and stacked on mobile; the password input and button are a row (button aligned to the bottom edge) on desktop and stacked, full width, on mobile.
- Not covered by screenshots: keyboard and focus order (Tab reaches the field, Save, "Send reset link"; Escape returns focus to the chip; focus returns to the field after a failed save), hover and active states, motion (popover and sheet, skeleton pulse 1.6 s, both stopped under reduced motion).
- Not drawn, no visual state: the leave-page dialog, the "Display name removed" toast, the 400 variants. Vitest only (cases 13, 15, 16).
Manual verification
- Real round trips against
task dev. Vitest only mocksuseApi. Save a display name and confirm the chip, menu, Home greeting and Users list show it after a reload; clear it and confirm the username returns; confirm login still works only with the username. - Unicode limit. Paste 60 and 61 emoji and a combining-mark name; the client counter and
the API (
utf8.RuneCountInString) agree at the boundary. - Real reset email (needs real or sandboxed SMTP,
SMTP_HOSTand related vars). "Send reset link" delivers a mail to the account's own address, the link ends on the reset-confirm screen, and a completed reset invalidates the current session (token_version). "Send again" invalidates the older link. - Real 429. Exceed
AUTH_RATE_LIMIT_MAX_ATTEMPTSwithinAUTH_RATE_LIMIT_WINDOW_SECONDSand confirm the button re-enables when the real Retry-After elapses. The limiter budget is shared with login. - API quirk, out of scope. POST /v1/auth/password-reset returns 200 even if the mail cannot be sent (the handler discards the use-case error), so with SMTP down the page still shows "Reset link sent". Reported, not fixed here.
- Mobile sheet and toasts on a real device. The drawer-to-sheet handoff, scrim, safe-area padding, and the toast placement (bottom center on mobile).
- Theme persistence. The choice made from the menu survives a reload.