diff --git a/features/dashboard_shell_branding.feature b/features/dashboard_shell_branding.feature index 0326f6a..188940b 100644 --- a/features/dashboard_shell_branding.feature +++ b/features/dashboard_shell_branding.feature @@ -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 Log out 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 diff --git a/features/home.feature b/features/home.feature index 176fa19..a455325 100644 --- a/features/home.feature +++ b/features/home.feature @@ -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 When the user views /home Then the greeting above the subtitle reads ", marta" diff --git a/features/profile_page.feature b/features/profile_page.feature new file mode 100644 index 0000000..7551f09 --- /dev/null +++ b/features/profile_page.feature @@ -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": ""}; + 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": ""}, 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 + When the user navigates to /profile + Then + + 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 into Display name + Then the counter reads " / 60" + And the counter is and Save is + + 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 "" + When the user sets Display name to , read literally as described in the column + Then Save is + And the footer reads "