Files
RenewCMS/features/profile_page.feature
T

455 lines
27 KiB
Gherkin

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 five
navigation items (Home, Articles, Users, Integration, Settings) 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, in the desktop popover, keeps the menu open (the mobile sheet closes);
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 every item is 32 px high
And "Profile" is not highlighted
And "System", "Light" and "Dark" form one group where choosing one applies the theme at once
and keeps the menu open
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 at 15 px text: "Profile", a separator, "System", "Light", "Dark",
a separator, "Log out"
And the sheet has the same items in the same order as the desktop popover, so it is no taller
than the board's sheet
Scenario: Account menu matches the board with no visual exception
Given web/visual/states.ts has no exception on the profile state "Account menu"
When `task visual:check -- profile "Account menu"` runs at desktop and mobile, light and dark
Then every capture is within 10 %
And System, Light and Dark are present in the popover and in the sheet
And item heights are 32 px on desktop and 48 px on mobile
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