feat(SPA): profile page, display name and the new account menu
This commit is contained in:
1 parent
55525c06d6
commit
b0f7b7892b
29 files changed
+3166
-469
No files matched your search
@@ -20,12 +20,14 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
|
||||
to the sidebar's own collapsed state, with items Home, Articles, Users and Integration, in that order
|
||||
And the sidebar's #footer slot renders a UserMenu component, not the plain
|
||||
<span>Log out</span> text the shell renders today
|
||||
And UserMenu is a UDropdownMenu :content="{ side: 'top', align: 'start' }" anchored to a
|
||||
ghost UButton block containing a UAvatar and the session's username
|
||||
And the UAvatar's initials are the first two characters of the username, upper-cased (for
|
||||
example "marta" gives "MA"), and the avatar is aria-hidden because the username is shown
|
||||
next to it
|
||||
And UserMenu's dropdown groups, top to bottom, are: an account label (the username); a
|
||||
And UserMenu is the account menu specified in features/profile_page.feature, anchored to a
|
||||
ghost UButton block containing a UAvatar and the session's name (the display name when not
|
||||
empty, else the username)
|
||||
And the UAvatar's initials follow features/profile_page.feature (the first letters of the
|
||||
first two words of a display name, the first two characters of a single word or of a
|
||||
username, upper-cased; for example "marta" gives "MA"), and the avatar is aria-hidden
|
||||
because the name is shown next to it
|
||||
And UserMenu's groups, top to bottom, are: a header (the name and the email); Profile; a
|
||||
checkbox group of System, Light and Dark bound to useColorMode().preference, with the
|
||||
current preference's item checked; then Log out, in its own trailing group
|
||||
And Log out calls POST /v1/auth/logout inside a try/finally with a loading flag (no catch),
|
||||
@@ -53,15 +55,16 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
|
||||
Then the sidebar returns to its :default-size="14" expanded width
|
||||
And aria-expanded reflects the expanded state
|
||||
|
||||
Scenario: UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out
|
||||
Given the session's username is "marta"
|
||||
Scenario: UserMenu lists the header, Profile, color-mode checkboxes, then Log out
|
||||
Given the session's username is "marta" and its email is "marta@renew.example"
|
||||
When the user activates the UserMenu trigger in the sidebar footer
|
||||
Then the dropdown opens with a UAvatar showing initials "MA" and the trigger's own username
|
||||
text "marta"
|
||||
And the first group is the account label showing "marta"
|
||||
And the second group is a checkbox group with items System, Light and Dark, and exactly the
|
||||
Then the menu opens with a UAvatar showing initials "MA" and the trigger's own name text
|
||||
"marta"
|
||||
And the first group is the header showing "marta" and "marta@renew.example"
|
||||
And the next item is "Profile"
|
||||
And the following group is a checkbox group with items System, Light and Dark, and exactly the
|
||||
item matching the current useColorMode().preference is checked
|
||||
And the third and final group contains only "Log out", separated from the color-mode group
|
||||
And the final group contains only "Log out", separated from the color-mode group
|
||||
|
||||
Scenario Outline: Selecting a color-mode item updates the preference and its checked state
|
||||
Given the user has opened the UserMenu dropdown
|
||||
@@ -91,11 +94,11 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
|
||||
And the header keeps an accessible name for the brand mark (an aria-label or sr-only text
|
||||
reading "RenewCMS" on the logo or its wrapper), so collapsing the rail is not a pure a11y
|
||||
regression
|
||||
And the UserMenu trigger in the sidebar footer still shows the UAvatar, but the username text
|
||||
And the UserMenu trigger in the sidebar footer still shows the UAvatar, but the name text
|
||||
next to it is hidden
|
||||
And the UserMenu trigger keeps an accessible name that includes the username (an aria-label
|
||||
And the UserMenu trigger keeps an accessible name that includes the name (an aria-label
|
||||
such as "marta — account menu", or a tooltip plus aria-label), matching the same
|
||||
tooltip/aria-label spirit the collapsed rail's UNavigationMenu items already use per
|
||||
docs/design/components.md's DashboardShell a11y note
|
||||
And activating the collapsed UserMenu trigger still opens the same dropdown, with the same
|
||||
account label, color-mode checkbox group and Log out item as the expanded state
|
||||
And activating the collapsed UserMenu trigger still opens the same menu, with the same
|
||||
header, Profile, color-mode checkbox group and Log out item as the expanded state
|
||||
@@ -18,8 +18,9 @@ Feature: Home screen
|
||||
Default, Empty and Error alike): icon plug, title "Connect your website", text "Your site
|
||||
reads published articles from RenewCMS. Share this guide with whoever builds it.", and a
|
||||
link "Open the guide" navigating to /integration
|
||||
And a greeting reading "{greeting}, {username}" sits above the subtitle in every state
|
||||
(Loading, Default, Empty and Error alike); it is computed from the session's username and
|
||||
And a greeting reading "{greeting}, {name}" sits above the subtitle in every state
|
||||
(Loading, Default, Empty and Error alike); name is the session's display name when it is
|
||||
not empty and the session's username otherwise; it is computed from that name and
|
||||
the device's local hour via Date.getHours(), needs no request of its own since the session
|
||||
is already resolved by the app-boot flow before HomePage ever renders, and so never has its
|
||||
own loading or error state
|
||||
@@ -109,7 +110,7 @@ Feature: Home screen
|
||||
second request made for either
|
||||
|
||||
Scenario Outline: The greeting matches the hour of day
|
||||
Given the session's username is "marta"
|
||||
Given the session's username is "marta" and it has no display name
|
||||
And the device's local hour is <hour>
|
||||
When the user views /home
|
||||
Then the greeting above the subtitle reads "<greeting>, marta"
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
Feature: Profile page, display name and the account menu
|
||||
|
||||
As a verified RenewCMS user in the shared workspace
|
||||
I want /profile to let me set an optional display name, see my read-only account details and
|
||||
email myself a password reset link, and I want the sidebar account chip to open a menu that
|
||||
leads there
|
||||
So that people are named by a human name across the back-office, while logging in still uses the
|
||||
username, and I always have a way to reach Profile, switch theme and log out
|
||||
|
||||
Background:
|
||||
Given /profile is a verified route (meta.auth "verified", meta.title "Profile") rendered as
|
||||
ProfilePage inside DashboardShell, per docs/design/routes.md and docs/design/screens/profile.md
|
||||
And the endpoints already exist and this slice wires the SPA to them with no Go change:
|
||||
GET /v1/auth/me (returns id, username, email, is_verified, display_name), PATCH /v1/user/me
|
||||
(features/user_display_name.feature) and POST /v1/auth/password-reset
|
||||
(features/request_password_reset.feature)
|
||||
And useApi's getMe and the Session type gain email and displayName; a missing or null
|
||||
display_name in a response is read as ""; the session store gains an action that replaces
|
||||
the session's displayName from the display_name of a PATCH /v1/user/me response
|
||||
And the PageHeader shows breadcrumb "Profile" (mobile title "Profile"), the page title
|
||||
"Profile" and the subtitle "Your account on this RenewCMS workspace."; none of the four
|
||||
navigation items (Home, Articles, Users, Integration) has aria-current="page" on /profile
|
||||
And the page is at most 720 px wide with three ProfileCards in this order: Display name,
|
||||
Account, Password; cards are separated by 20 px, have 20 px padding (16 px on mobile) and
|
||||
inputs are 40 px high (44 px on mobile, 16 px text)
|
||||
And the Display name card has the title "Display name", the text "Shown instead of your
|
||||
username across the back-office and in the emails RenewCMS sends. Leave it empty to use your
|
||||
username.", one input labelled "Display name (optional)" with placeholder "e.g. Marta Alves",
|
||||
and a help row under the input: the field error on the left (icon circle-alert, 12 px, error
|
||||
color) and the counter "n / 60" on the right in the mono style; there is no maxlength, so
|
||||
typing past 60 is possible and shows the error
|
||||
And the Display name card's footer strip (top border, --ui-bg-muted, 12 px status text on the
|
||||
left, Save button on the right, 32 px high on desktop and 44 px on mobile) shows "Unsaved
|
||||
changes" in the warning color while the draft is dirty and no save is in flight; otherwise
|
||||
it shows "You appear as “{display name}”." when a display name is saved, or "You appear as
|
||||
“{username}”, your username." when none is
|
||||
And the draft is the trimmed field value (String.prototype.trim); it is dirty when it differs
|
||||
from the session's displayName; it is valid when its length counted in Unicode code points
|
||||
(Array.from(value).length, the same count as the API's utf8.RuneCountInString, not bytes,
|
||||
not UTF-16 units and not grapheme clusters) is 60 or fewer; the counter shows that count
|
||||
and turns red (error color) above 60
|
||||
And Save is enabled (full opacity) while the draft is dirty and valid, or while a save is in
|
||||
flight; otherwise it is disabled at 40 % opacity; pressing Enter in the field activates Save
|
||||
only when Save is enabled, and does nothing otherwise
|
||||
And activating Save sends PATCH /v1/user/me with JSON {"display_name": "<trimmed draft>"};
|
||||
while it is in flight the field is disabled and dimmed, the button reads "Saving…" with a
|
||||
loader-circle icon, and a second submit is ignored
|
||||
And a 200 response replaces session.displayName with the response's display_name at once (the
|
||||
server's value, not the typed one), sets the field to that value, and raises a toast:
|
||||
color success, icon i-lucide-circle-check, id "display-name-saved", title "Display name
|
||||
saved", description "You now appear as {display name} across RenewCMS and in emails."
|
||||
And the display name is shown wherever a person is named, read from the session: the sidebar
|
||||
chip and the account menu (name and initials) and the Home greeting "{greeting}, {name}",
|
||||
where name is the display name when not empty and the username otherwise; the Users list
|
||||
reads display_name from its own GET /v1/user/ items and is not changed by this slice
|
||||
And any non-2xx or failed PATCH keeps the typed value and the dirty state; a 401 and a 403
|
||||
are handled entirely by useApi's global handling
|
||||
(features/api_client_global_error_handling.feature)
|
||||
And the Account card has the title "Account", the text "Set when your account was created. You
|
||||
log in with your username, even after adding a display name.", and two disabled inputs with
|
||||
a lock icon at the right: "Username" (the session's username) and "Email" (the session's
|
||||
email), in two columns on desktop (minmax(0,1fr) minmax(0,1.4fr)) and stacked on mobile
|
||||
And the Password card has the title "Password", the text "To change it, we'll email a reset
|
||||
link to {email}. The link works once, within 1 hour.", a disabled input labelled "Password"
|
||||
showing "••••••••••••" with a lock icon, and, beside it (stacked on mobile), a secondary
|
||||
button "Send reset link" with the mail icon; the note under them exists only once a request
|
||||
has been made
|
||||
And activating the button sends POST /v1/auth/password-reset with JSON {"email": "<session
|
||||
email>"}, with no confirmation step; while in flight the button reads "Sending…" with a
|
||||
loader-circle icon and is disabled; the note is role="status", 16 px icon, 600-weight title
|
||||
and the text on a soft background
|
||||
And leaving /profile while the draft is dirty (a route change, including Log out) opens a
|
||||
ConfirmDialog, except when the redirect is caused by a 401 handled by useApi
|
||||
(authRedirectPending); leaving while the draft is not dirty, or after a successful save,
|
||||
opens nothing
|
||||
And the UserMenu chip in the sidebar footer no longer logs out on click: it opens the account
|
||||
menu; the chip is 40 px high with a 26 px sand (#d4c5b0) initials avatar with ink text, the
|
||||
name (display name, else username) with truncation, and a chevrons-up-down icon; it has
|
||||
aria-haspopup="menu", aria-expanded reflecting the open state, and title "Account"
|
||||
And while the menu is open, or while the current route is /profile, the chip takes --rc-sand
|
||||
with #111111 text
|
||||
And on desktop (lg and up) the menu is a 224 px popover above the chip aligned to its left
|
||||
edge with --rc-shadow-pop; below lg the account row in the navigation drawer closes the
|
||||
drawer and opens the menu as a bottom sheet with a grab handle over an --rc-overlay scrim;
|
||||
Escape or a click outside closes either, and focus returns to the chip; arrow keys move
|
||||
between items
|
||||
And the menu content, top to bottom, is: a header (the name in semibold, and the email below
|
||||
it in muted 12 px on desktop; on mobile a 36 px initials avatar, the name at 15 px semibold
|
||||
and the email at 14 px muted), a "Profile" item (icon circle-user, highlighted with
|
||||
--ui-bg-elevated while the route is /profile), a separator, the three checkbox items
|
||||
"System", "Light" and "Dark" bound to useColorMode().preference with the current one
|
||||
checked (kept from the shipped menu), a separator, and "Log out" (icon log-out); items are
|
||||
32 px on desktop and 48 px at 15 px text on mobile
|
||||
And activating "Profile" navigates to /profile and closes the menu (when already on /profile
|
||||
it only closes); activating a theme item sets the preference and closes the menu;
|
||||
activating "Log out" behaves as today: POST /v1/auth/logout in a try/finally, then
|
||||
session.clear() and a navigation to /login
|
||||
And avatar initials are the uppercased first Unicode character of each of the first two words
|
||||
of the display name; a display name with a single word, and a username, give their first two
|
||||
characters, uppercased
|
||||
And in the collapsed sidebar rail the chip shows only the avatar, keeps its accessible name
|
||||
"{name} — account menu" and opens the same menu
|
||||
And this slice does not touch the invitation or accept-invite work, does not change the
|
||||
Users page, and does not add a way to change the username, email or password inside the page
|
||||
|
||||
Scenario Outline: Route guards for /profile
|
||||
Given the session is <session>
|
||||
When the user navigates to /profile
|
||||
Then <outcome>
|
||||
|
||||
Examples:
|
||||
| session | outcome |
|
||||
| resolved, verified | ProfilePage renders |
|
||||
| resolved, unverified | the app redirects to /register/pending |
|
||||
| resolved with no session (401 from /auth/me) | the app redirects to /login?redirect=/profile&reason=expired |
|
||||
|
||||
Scenario: Default — the usual first visit with no display name
|
||||
Given the session is verified with id 1, username "marta", email "marta@renew.example" and no
|
||||
display name
|
||||
When the user navigates to /profile
|
||||
Then the breadcrumb reads "Profile" (mobile title "Profile")
|
||||
And the Display name field is empty, shows the placeholder "e.g. Marta Alves", and the counter
|
||||
reads "0 / 60"
|
||||
And the footer reads "You appear as “marta”, your username."
|
||||
And Save is disabled
|
||||
And the Username input shows "marta" and the Email input shows "marta@renew.example", both
|
||||
disabled
|
||||
And the Password card text reads "To change it, we'll email a reset link to
|
||||
marta@renew.example. The link works once, within 1 hour."
|
||||
And the button reads "Send reset link", no note is shown, and no request other than the
|
||||
session's was sent
|
||||
|
||||
Scenario: Loading — opened by URL before /auth/me has returned
|
||||
Given GET /v1/auth/me has not yet resolved
|
||||
When the user opens /profile directly by URL
|
||||
Then the breadcrumb reads "Profile", and the page title and subtitle are shown
|
||||
And three skeleton cards replace the real cards (one with a title bar and one input, one with a
|
||||
title bar and two inputs, one with a title bar and one input), 16 px apart, in an
|
||||
aria-busy="true" container
|
||||
And AppBoot is not shown
|
||||
When GET /v1/auth/me then resolves 200 for the verified user "marta"
|
||||
Then the skeleton cards are replaced by the Default state
|
||||
|
||||
Scenario: Opened from the menu, the cached user renders at once
|
||||
Given the user is on /home with a resolved session for "marta"
|
||||
When the user activates "Profile" in the account menu
|
||||
Then the Default state renders with no skeleton, and GET /v1/auth/me is not called again
|
||||
|
||||
Scenario: Editing — typing turns Save on
|
||||
Given the user is on /profile in the Default state
|
||||
When the user types "Marta Alves" into Display name
|
||||
Then the counter reads "11 / 60" and the footer reads "Unsaved changes" in the warning color
|
||||
And Save is enabled
|
||||
And no request has been sent
|
||||
|
||||
Scenario Outline: The counter counts Unicode characters after trimming
|
||||
Given the user is on /profile in the Default state
|
||||
When the user types <value> into Display name
|
||||
Then the counter reads "<count> / 60"
|
||||
And the counter is <color> and Save is <save>
|
||||
|
||||
Examples:
|
||||
| value | count | color | save |
|
||||
| "Marta Alves" | 11 | muted | enabled |
|
||||
| " Marta Alves " | 11 | muted | enabled |
|
||||
| 60 repetitions of "a" | 60 | muted | enabled |
|
||||
| 61 repetitions of "a" | 61 | red | disabled |
|
||||
| 60 repetitions of "😀" | 60 | muted | enabled |
|
||||
| 61 repetitions of "😀" | 61 | red | disabled |
|
||||
| "Cafe" followed by U+0301 | 5 | muted | enabled |
|
||||
| 5 spaces, 60 "a", 5 spaces | 60 | muted | enabled |
|
||||
|
||||
Scenario: Too long — over 60 characters shows a field error and keeps Save off
|
||||
Given the user is on /profile in the Default state
|
||||
When the user types "Marta Alves, Head of Renovations, Brussels and Wallonia office"
|
||||
Then the counter reads "62 / 60" in the error color
|
||||
And the help row shows the error "Use 60 characters or fewer." with the circle-alert icon
|
||||
And the field has the error border
|
||||
And the footer reads "Unsaved changes" in the warning color
|
||||
And Save is disabled
|
||||
When the user presses Enter in the field
|
||||
Then PATCH /v1/user/me is never sent
|
||||
When the user shortens the value to "Marta Alves"
|
||||
Then the error disappears, the counter reads "11 / 60" and Save is enabled
|
||||
|
||||
Scenario Outline: Dirty is measured on the trimmed draft against the saved name
|
||||
Given the session's display name is "<saved>"
|
||||
When the user sets Display name to <typed>, read literally as described in the column
|
||||
Then Save is <save>
|
||||
And the footer reads "<footer>"
|
||||
|
||||
Examples:
|
||||
| saved | typed | save | footer |
|
||||
| | 3 spaces | disabled | You appear as “marta”, your username. |
|
||||
| Marta Alves | Marta Alves | disabled | You appear as “Marta Alves”. |
|
||||
| Marta Alves | Marta Alves and a space | disabled | You appear as “Marta Alves”. |
|
||||
| Marta Alves | nothing | enabled | Unsaved changes |
|
||||
| Marta Alves | Marta, 2 spaces, Alves | enabled | Unsaved changes |
|
||||
|
||||
Scenario: Enter saves when Save is enabled
|
||||
Given the user is on /profile and has typed "Marta Alves"
|
||||
When the user presses Enter in the field
|
||||
Then PATCH /v1/user/me is sent once with JSON {"display_name": "Marta Alves"}
|
||||
|
||||
Scenario: Saving — the field is disabled and a second submit is ignored
|
||||
Given the user is on /profile and has typed " Marta Alves " and PATCH /v1/user/me has not
|
||||
resolved
|
||||
When the user activates Save
|
||||
Then PATCH /v1/user/me is sent with JSON {"display_name": "Marta Alves"}
|
||||
And the field is disabled and dimmed, and the button reads "Saving…" with a loader-circle icon
|
||||
When the user presses Enter or activates Save again
|
||||
Then no second request is sent
|
||||
|
||||
Scenario: Saved — a 200 updates the session, the chip, the menu, the footer and raises a toast
|
||||
Given the user is on /profile and Save has been activated with "Marta Alves"
|
||||
When PATCH /v1/user/me resolves 200 with id 1, username "marta" and display_name "Marta Alves"
|
||||
Then session.displayName is "Marta Alves"
|
||||
And the sidebar chip reads "Marta Alves" with initials "MA"
|
||||
And the field shows "Marta Alves" and is enabled, the counter reads "11 / 60"
|
||||
And the footer reads "You appear as “Marta Alves”." and Save is disabled
|
||||
And a toast appears: color success, icon i-lucide-circle-check, id "display-name-saved", title
|
||||
"Display name saved", description "You now appear as Marta Alves across RenewCMS and in
|
||||
emails."
|
||||
And no leave-page confirmation opens when the user then navigates away
|
||||
|
||||
Scenario: The server's value wins over what was typed
|
||||
Given the user has typed " Marta Alves " and activated Save
|
||||
When PATCH /v1/user/me resolves 200 with display_name "Marta Alves"
|
||||
Then the field, the chip and the toast all use "Marta Alves"
|
||||
|
||||
Scenario: Clearing the display name goes back to the username
|
||||
Given the session's display name is "Marta Alves" and the username is "marta"
|
||||
When the user empties Display name and activates Save
|
||||
Then PATCH /v1/user/me is sent with JSON {"display_name": ""}
|
||||
And when it resolves 200 with display_name "" the sidebar chip reads "marta" with initials "MA"
|
||||
And the footer reads "You appear as “marta”, your username."
|
||||
And a toast appears: color success, icon i-lucide-circle-check, id "display-name-saved", title
|
||||
"Display name removed", description "You now appear as marta, your username."
|
||||
|
||||
Scenario Outline: Save failed — a 500 or a network failure keeps the text and offers a retry
|
||||
Given the user is on /profile and Save has been activated with "Marta Alves"
|
||||
When PATCH /v1/user/me <failure>
|
||||
Then an error alert (role="alert", icon circle-x) in the Display name card reads "Couldn't save
|
||||
your display name. Your text is still here. Try again in a moment."
|
||||
And the field still shows "Marta Alves" and is enabled
|
||||
And the footer reads "Unsaved changes" and Save is enabled
|
||||
And session.displayName is unchanged and no toast is raised
|
||||
When the user activates Save again
|
||||
Then the alert disappears while the request is in flight
|
||||
And PATCH /v1/user/me is sent again with the same body
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario Outline: A 400 from the API
|
||||
Given the user is on /profile and Save has been activated with "Marta Alves"
|
||||
When PATCH /v1/user/me resolves 400 with body "<body>"
|
||||
Then <effect>
|
||||
|
||||
Examples:
|
||||
| body | effect |
|
||||
| Display name must be 60 characters or fewer. | the field shows the error "Use 60 characters or fewer." and Save is disabled |
|
||||
| invalid request body | the Save failed alert is shown and the text is kept |
|
||||
|
||||
Scenario: Leaving with unsaved changes asks for confirmation
|
||||
Given the user is on /profile with the draft "Marta Alves" (dirty)
|
||||
When the user activates "Users" in the navigation
|
||||
Then a ConfirmDialog (neutral tone) opens, titled "Leave without saving?" with the text "Your
|
||||
display name change hasn't been saved."
|
||||
And its actions are "Discard changes" and "Keep editing"
|
||||
When the user activates "Keep editing"
|
||||
Then the dialog closes and the app stays on /profile with the draft intact
|
||||
When the user tries to leave again and activates "Discard changes"
|
||||
Then the app navigates to /users
|
||||
And the display name saved in the session is unchanged
|
||||
|
||||
Scenario: No confirmation when nothing is unsaved
|
||||
Given the user is on /profile in the Default state
|
||||
When the user activates "Users" in the navigation
|
||||
Then the app navigates to /users with no dialog
|
||||
|
||||
Scenario: A 401 while saving redirects without the leave dialog
|
||||
Given the user is on /profile with the draft "Marta Alves"
|
||||
When PATCH /v1/user/me resolves 401
|
||||
Then the app redirects to /login?redirect=/profile&reason=expired
|
||||
And no leave-page dialog opens
|
||||
|
||||
Scenario: Account menu — opened on desktop
|
||||
Given the user is on /home on desktop with the session "marta", email "marta@renew.example",
|
||||
no display name
|
||||
When the user activates the account chip
|
||||
Then the chip has aria-expanded="true" and the sand fill with ink text
|
||||
And a 224 px popover opens above the chip, aligned to its left edge
|
||||
And its header shows "marta" in semibold and "marta@renew.example" in muted 12 px
|
||||
And below it are "Profile" (circle-user), a separator, "System", "Light", "Dark" (the
|
||||
current preference checked), a separator, and "Log out" (log-out)
|
||||
And "Profile" is not highlighted
|
||||
|
||||
Scenario: Account menu — opened from the drawer on mobile
|
||||
Given the user is on /home on a viewport below lg and the navigation drawer is open
|
||||
When the user activates the account row in the drawer
|
||||
Then the drawer closes and a bottom sheet with a grab handle opens over the --rc-overlay scrim,
|
||||
with the Home page behind it
|
||||
And its header shows a 36 px avatar "MA", the name "marta" at 15 px semibold and the email
|
||||
"marta@renew.example" muted
|
||||
And its items are 48 px high: "Profile", a separator, "System", "Light", "Dark", a separator,
|
||||
"Log out"
|
||||
|
||||
Scenario Outline: Closing the menu
|
||||
Given the account menu is open
|
||||
When <action>
|
||||
Then the menu closes, the chip has aria-expanded="false", and focus is on the chip
|
||||
|
||||
Examples:
|
||||
| action |
|
||||
| the user presses Escape |
|
||||
| the user clicks outside the menu |
|
||||
|
||||
Scenario: Menu items
|
||||
Given the account menu is open on /home
|
||||
When the user activates "Profile"
|
||||
Then the app navigates to /profile and the menu closes
|
||||
Given the account menu is open on /profile
|
||||
Then "Profile" is highlighted with --ui-bg-elevated
|
||||
When the user activates "Profile"
|
||||
Then the menu closes and the app stays on /profile with no new request
|
||||
When the user opens the menu and activates "Log out"
|
||||
Then POST /v1/auth/logout is sent, then the session is cleared and the app navigates to /login
|
||||
|
||||
Scenario: The chip is sand while on /profile even with the menu closed
|
||||
Given the user is on /profile with the menu closed
|
||||
Then the chip has the --rc-sand fill and #111111 text, and aria-expanded="false"
|
||||
When the user navigates to /home
|
||||
Then the chip has a transparent fill
|
||||
|
||||
Scenario: Theme choice stays in the menu
|
||||
Given the account menu is open and useColorMode().preference is "system"
|
||||
When the user activates "Dark"
|
||||
Then useColorMode().preference is "dark"
|
||||
And the menu closes
|
||||
And reopening it shows "Dark" checked and "System" not checked
|
||||
|
||||
Scenario Outline: Avatar initials
|
||||
Given the session has username "<username>" and display name "<display>"
|
||||
Then the chip and the menu header show the initials "<initials>" and the name "<shown>"
|
||||
|
||||
Examples:
|
||||
| username | display | initials | shown |
|
||||
| marta | | MA | marta |
|
||||
| marta | Marta Alves | MA | Marta Alves |
|
||||
| marta | Marta Alves Costa | MA | Marta Alves Costa |
|
||||
| ines | Inès Duarte | ID | Inès Duarte |
|
||||
| elodie | Élodie Martin | ÉM | Élodie Martin |
|
||||
| marta | Marta | MA | Marta |
|
||||
| m.alves | | M. | m.alves |
|
||||
|
||||
Scenario: The collapsed rail keeps the menu and an accessible name
|
||||
Given the sidebar is collapsed to its icon rail and the display name is "Marta Alves"
|
||||
Then the chip shows only the avatar "MA" and has the accessible name "Marta Alves — account menu"
|
||||
When the user activates it
|
||||
Then the same menu opens
|
||||
|
||||
Scenario Outline: The Home greeting uses the display name, else the username
|
||||
Given the session has username "marta" and display name "<display>"
|
||||
When the user views /home at a local hour of 9
|
||||
Then the greeting's name is "<name>"
|
||||
|
||||
Examples:
|
||||
| display | name |
|
||||
| | marta |
|
||||
| Marta Alves | Marta Alves |
|
||||
|
||||
Scenario: A saved display name appears on Home without a reload
|
||||
Given the user saved the display name "Marta Alves" on /profile
|
||||
When the user navigates to /home
|
||||
Then the greeting's name is "Marta Alves" and the chip reads "Marta Alves"
|
||||
|
||||
Scenario: Reset: sending — no confirmation, the button is busy
|
||||
Given the user is on /profile with the session's display name "Marta Alves"
|
||||
When the user activates "Send reset link"
|
||||
Then POST /v1/auth/password-reset is sent with JSON {"email": "marta@renew.example"}
|
||||
And the button reads "Sending…" with a loader-circle icon and is disabled
|
||||
And no note is shown, and the sidebar chip still reads "Marta Alves"
|
||||
|
||||
Scenario: Reset: sent — the note stays and "Send again" sends a new link
|
||||
Given "Send reset link" has been activated
|
||||
When POST /v1/auth/password-reset resolves 200
|
||||
Then the button reads "Send again" with the mail icon and is enabled
|
||||
And a success note (role="status", icon mail-check) shows the title "Reset link sent" and the
|
||||
text "Check marta@renew.example. The link works once, within 1 hour."
|
||||
When the user activates "Send again"
|
||||
Then POST /v1/auth/password-reset is sent again with the same body
|
||||
And the note is hidden while it is in flight
|
||||
When it resolves 200
|
||||
Then the "Reset link sent" note is shown again
|
||||
When the user navigates away and back to /profile
|
||||
Then the button reads "Send reset link" and no note is shown
|
||||
|
||||
Scenario Outline: Reset: too many requests — the button is disabled for the Retry-After window
|
||||
Given "Send reset link" has been activated
|
||||
When POST /v1/auth/password-reset resolves 429 with <header>
|
||||
Then a warning note (role="status", icon clock-alert) shows the title "Too many requests" and
|
||||
the text "You've asked for several links recently. Wait a few minutes, then try again."
|
||||
And the button reads "Send reset link", is disabled and is at 40 % opacity
|
||||
When <seconds> seconds elapse
|
||||
Then the button is enabled and the warning note is gone
|
||||
|
||||
Examples:
|
||||
| header | seconds |
|
||||
| a Retry-After of 42 | 42 |
|
||||
| a Retry-After of 300 | 300 |
|
||||
| no Retry-After header | 300 |
|
||||
| a Retry-After of "soon" | 300 |
|
||||
| a Retry-After of 0 | 300 |
|
||||
|
||||
Scenario Outline: Reset: failed — a 500 or a network failure leaves the button pressable
|
||||
Given "Send reset link" has been activated
|
||||
When POST /v1/auth/password-reset <failure>
|
||||
Then an error note (role="status", icon circle-x) shows the title "Couldn't send the link" and
|
||||
the text "Check your connection and try again."
|
||||
And the button reads "Send reset link" and is enabled
|
||||
When the user activates it and POST /v1/auth/password-reset resolves 200
|
||||
Then the note becomes "Reset link sent" and the button reads "Send again"
|
||||
|
||||
Examples:
|
||||
| failure |
|
||||
| resolves with a 500 response |
|
||||
| never reaches the server |
|
||||
|
||||
Scenario: Display names in the Users list
|
||||
Given the session is "marta" with display name "Marta Alves"
|
||||
And GET /v1/user/ resolves 200 with the 5-item Default fixture of users_screen.feature, where
|
||||
user id 1 "marta" has display_name "Marta Alves" and user id 3 "ines" has display_name
|
||||
"Inès Duarte"
|
||||
When the user navigates to /users
|
||||
Then the breadcrumb reads "Users" (mobile title "Users") and the sidebar chip reads "Marta Alves"
|
||||
And the "marta" row shows "Marta Alves" with "marta" beside it in muted text and the "you" tag
|
||||
and an Accepted badge
|
||||
And the "ines" row shows "Inès Duarte" with "ines" beside it
|
||||
And the "jonas" row shows "jonas" alone
|
||||
@@ -0,0 +1,164 @@
|
||||
# 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`).
|
||||
|
||||
1. **Route guards, Examples-driven** - verified renders; unverified redirects to
|
||||
/register/pending; 401 redirects to /login?redirect=/profile&reason=expired.
|
||||
2. **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.
|
||||
3. **Loading** - unresolved GET /v1/auth/me; assert three skeleton cards, aria-busy, no AppBoot;
|
||||
resolve and assert Default.
|
||||
4. **From the menu** - navigate from /home via "Profile"; assert no skeleton and no second
|
||||
GET /v1/auth/me.
|
||||
5. **Editing** - type "Marta Alves"; assert "11 / 60", "Unsaved changes", Save enabled, no request.
|
||||
6. **Counter, Examples-driven** - each table row, including the 60/61 emoji rows and "Cafe" +
|
||||
U+0301 (5); assert count, color class and Save state.
|
||||
7. **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.
|
||||
8. **Dirty rules, Examples-driven** - each row.
|
||||
9. **Enter saves** - assert one PATCH with body `{"display_name":"Marta Alves"}`.
|
||||
10. **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.
|
||||
11. **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.
|
||||
12. **Server value wins** - resolve with a trimmed display_name; assert all consumers use it.
|
||||
13. **Clearing** - saved "Marta Alves", empty the field, save; assert body `{"display_name":""}`,
|
||||
chip "marta", footer, and the "Display name removed" toast.
|
||||
14. **Save failed, Examples-driven** - `ApiError(500)` and `NetworkError`; 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.
|
||||
15. **400, Examples-driven** - `ApiError(400)` with the display-name text gives the field error;
|
||||
with another body gives the Save failed alert.
|
||||
16. **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.
|
||||
17. **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.
|
||||
18. **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.
|
||||
19. **Closing** - Escape and an outside click; assert closed, aria-expanded false, focus on the
|
||||
chip; ArrowDown and ArrowUp move between items.
|
||||
20. **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.
|
||||
21. **Chip fill** - sand on /profile with the menu closed; not sand on /home.
|
||||
22. **Theme** - activating "Dark" sets `useColorMode().preference`, closes the menu, and reopening
|
||||
shows "Dark" checked.
|
||||
23. **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).
|
||||
24. **Collapsed rail** - avatar only, aria-label "Marta Alves — account menu", same menu.
|
||||
25. **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.
|
||||
26. **Reset: sending** - unresolved POST; assert body `{"email":"marta@renew.example"}`, "Sending…",
|
||||
disabled, no note.
|
||||
27. **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.
|
||||
28. **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).
|
||||
29. **Reset: failed, Examples-driven** - `ApiError(500)` and `NetworkError`; assert the error note
|
||||
copy, enabled button, and that a retry resolving 200 gives the sent note.
|
||||
30. **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: boardNow` and the home Default fixtures, session as above,
|
||||
menu opened by clicking the chip (on mobile: open the drawer, then the account row). Name
|
||||
"marta", initials "MA", email "marta@renew.example". The theme items kept by user decision
|
||||
are not drawn on the board, so the mobile capture is expected to exceed 10 %. The user has
|
||||
approved a per-state `exception` for it, to be added with `approvedBy` only after the
|
||||
real difference is measured and reported to the user with its cause.
|
||||
- [ ] 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 -- home` still PASSes for Default, Loading, Empty and Error. The session
|
||||
fixture has display_name "", so the greeting name is "marta". Extend the fixture with
|
||||
`email` and `display_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 mocks `useApi`. 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_HOST` and 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_ATTEMPTS` within `AUTH_RATE_LIMIT_WINDOW_SECONDS` and
|
||||
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.
|
||||
Reference in new issue
Block a user