feat(SPA): profile page, display name and the new account menu

This commit is contained in:
fsylvain committed 2026-09-29 10:13:09 +02:00
1 parent 55525c06d6
commit b0f7b7892b
29 files changed
+3121 -424

No files matched your search

+20 -17
View File
@@ -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
+4 -3
View File
@@ -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"
+442
View File
@@ -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
+164
View File
@@ -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.
+150
View File
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ColorModePreference } from '@/composables/useColorMode'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
const ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"]'
const ITEM_CLASS = 'h-12 justify-start gap-3 px-4 text-[0.9375rem] text-highlighted'
const props = defineProps<{
username: string
displayName?: string
email?: string
}>()
const emit = defineEmits<{
logout: []
}>()
const open = defineModel<boolean>('open', { default: false })
const {
colorMode,
name,
initials,
onProfile,
goToProfile: navigateToProfile,
} = useAccountMenu(() => props)
const menu = ref<HTMLElement | null>(null)
function goToProfile() {
open.value = false
navigateToProfile()
}
function chooseColorMode(value: ColorModePreference) {
colorMode.preference = value
open.value = false
}
function logOut() {
open.value = false
emit('logout')
}
function moveFocus(event: KeyboardEvent) {
const items = Array.from(menu.value?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
if (items.length === 0) {
return
}
const current = items.indexOf(document.activeElement as HTMLElement)
const lastIndex = items.length - 1
const targets: Record<string, number> = {
ArrowDown: current >= lastIndex ? 0 : current + 1,
ArrowUp: current <= 0 ? lastIndex : current - 1,
Home: 0,
End: lastIndex,
}
const target = targets[event.key]
if (target === undefined) {
return
}
event.preventDefault()
items[target]?.focus()
}
</script>
<template>
<USlideover
v-model:open="open"
side="bottom"
title="Account"
description="Your account menu"
:close="false"
:ui="{
content: 'max-h-none',
body: 'p-0 sm:p-0',
header: 'sr-only',
}"
>
<template #body>
<div ref="menu" role="menu" aria-label="Account" class="pt-2 pb-6" @keydown="moveFocus">
<div class="mx-auto mb-2 h-1 w-9 bg-accented" aria-hidden="true" />
<div class="mb-1 flex items-center gap-3 border-b border-default px-4 pt-2 pb-3.5">
<UAvatar
aria-hidden="true"
:text="initials"
:ui="{
root: 'size-9 bg-secondary-300',
fallback: 'text-[0.8125rem] font-semibold text-primary-900',
}"
/>
<div class="flex min-w-0 flex-col">
<span class="truncate text-[0.9375rem] font-semibold text-highlighted">{{
name
}}</span>
<span class="truncate text-muted">{{ email }}</span>
</div>
</div>
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-circle-user"
:class="[ITEM_CLASS, onProfile ? 'bg-elevated' : '']"
:ui="{ leadingIcon: 'size-4.5' }"
@click="goToProfile"
>
Profile
</UButton>
<USeparator class="my-1" />
<UButton
v-for="option in colorModeOptions"
:key="option.value"
role="menuitemcheckbox"
color="neutral"
variant="ghost"
block
:aria-checked="colorMode.preference === option.value"
:trailing-icon="
colorMode.preference === option.value ? 'i-lucide-check' : undefined
"
:class="ITEM_CLASS"
:ui="{ trailingIcon: 'ms-auto size-4.5' }"
@click="chooseColorMode(option.value)"
>
{{ option.label }}
</UButton>
<USeparator class="my-1" />
<UButton
role="menuitem"
color="neutral"
variant="ghost"
block
icon="i-lucide-log-out"
:class="ITEM_CLASS"
:ui="{ leadingIcon: 'size-4.5' }"
@click="logOut"
>
Log out
</UButton>
</div>
</template>
</USlideover>
</template>
+7 -1
View File
@@ -7,7 +7,7 @@ const props = withDefaults(
max: number
min?: number
warnAt?: number
variant?: 'inline' | 'footer'
variant?: 'inline' | 'footer' | 'field'
plainText?: boolean
}>(),
{
@@ -113,6 +113,12 @@ const isErrorStatus = computed(() => status.value === 'over' || status.value ===
</span>
<span aria-live="polite" class="sr-only">{{ announcement }}</span>
</div>
<span
v-else-if="variant === 'field'"
class="font-mono text-xs leading-tight"
:class="overBy > 0 ? 'text-error' : 'text-muted'"
>{{ formatThousands(value) }} / {{ formatThousands(max) }}</span
>
<div v-else class="flex flex-col gap-1">
<div
class="flex items-center gap-1.5 font-mono text-xs"
+28 -11
View File
@@ -5,6 +5,7 @@ import { useSessionStore } from '@/stores/session'
import { useApi } from '@/composables/useApi'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue'
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
@@ -14,10 +15,12 @@ const props = withDefaults(
defineProps<{
mobileTitle?: string
fullBleed?: boolean
renderWhileChecking?: boolean
}>(),
{
mobileTitle: undefined,
fullBleed: false,
renderWhileChecking: false,
},
)
@@ -63,6 +66,15 @@ const navUi = computed(() => ({
linkLeadingIcon: 'size-4',
}))
const loggingOut = ref(false)
const sidebarOpen = ref(false)
const accountSheetOpen = ref(false)
function openAccountSheet() {
sidebarOpen.value = false
accountSheetOpen.value = true
}
const showBoot = computed(() => session.status === 'checking' && !props.renderWhileChecking)
function onRetry() {
session.retry()
@@ -82,11 +94,10 @@ async function onLogout() {
<template>
<UDashboardGroup unit="rem">
<a href="#dashboard-content" class="sr-only focus:not-sr-only"
>Skip to content</a
>
<a href="#dashboard-content" class="sr-only focus:not-sr-only">Skip to content</a>
<UDashboardSidebar
v-model:open="sidebarOpen"
v-model:collapsed="sidebarCollapsed"
collapsible
:resizable="false"
@@ -115,8 +126,11 @@ async function onLogout() {
<template #footer>
<UserMenu
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
:collapsed="sidebarCollapsed"
@logout="onLogout"
@open-sheet="openAccountSheet"
/>
</template>
</UDashboardSidebar>
@@ -164,26 +178,29 @@ async function onLogout() {
<template #body>
<div
id="dashboard-content"
:class="
fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''
"
:class="fullBleed ? 'flex h-full min-h-0 flex-1 flex-col' : ''"
>
<OfflineBanner />
<AppBoot v-if="session.status === 'checking'" />
<AppBoot v-if="showBoot" />
<ErrorView
v-else-if="session.status === 'error'"
kind="error"
@retry="onRetry"
/>
<div
v-else
:class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''"
>
<div v-else :class="fullBleed ? 'flex min-h-0 flex-1 flex-col' : ''">
<slot />
</div>
</div>
</template>
</UDashboardPanel>
<AccountMenuSheet
v-model:open="accountSheetOpen"
:username="session.session?.username ?? ''"
:display-name="session.session?.displayName"
:email="session.session?.email"
@logout="onLogout"
/>
</UDashboardGroup>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
defineProps<{
title: string
description: string
}>()
</script>
<template>
<section class="border border-default bg-default">
<div class="flex flex-col gap-3.5 p-4 lg:p-5">
<div class="flex flex-col gap-0.5">
<h2 class="text-base/5 font-semibold text-highlighted">{{ title }}</h2>
<p class="text-pretty text-muted">{{ description }}</p>
</div>
<slot />
</div>
<div
v-if="$slots.footer"
class="flex items-center gap-3 border-t border-default bg-muted px-4 py-3 lg:px-5"
>
<slot name="footer" />
</div>
</section>
</template>
+64 -31
View File
@@ -1,43 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import {
useColorMode,
type ColorModePreference,
} from '@/composables/useColorMode'
import { computed, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { colorModeOptions, useAccountMenu } from '@/composables/useAccountMenu'
const DESKTOP_QUERY = '(min-width: 64rem)'
const props = defineProps<{
username: string
displayName?: string
email?: string
collapsed: boolean
}>()
const emit = defineEmits<{
logout: []
'open-sheet': []
}>()
const colorMode = useColorMode()
const { colorMode, name, initials, onProfile, goToProfile } = useAccountMenu(() => props)
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const open = ref(false)
const initials = computed(() => {
const parts = props.username.split(/[^a-zA-Z0-9]+/).filter(Boolean)
if (parts.length >= 2) {
return (
(parts[0] ?? '').charAt(0) + (parts[1] ?? '').charAt(0)
).toUpperCase()
}
return (parts[0] ?? '').slice(0, 2).toUpperCase()
})
const chipActive = computed(() => open.value || onProfile.value)
const triggerAriaLabel = computed(() =>
props.collapsed ? `${props.username} — account menu` : undefined,
props.collapsed ? `${name.value} — account menu` : undefined,
)
const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]
const chipClass = computed(() => [
'h-10 gap-2.5 px-1.5 font-normal',
props.collapsed ? 'justify-center' : 'justify-start',
chipActive.value
? 'bg-secondary-200 text-primary-900 hover:bg-secondary-200 hover:text-primary-900 dark:bg-secondary-300 dark:hover:bg-secondary-300'
: 'text-highlighted',
])
function onUpdateOpen(value: boolean) {
if (isDesktop.value) {
open.value = value
return
}
if (value) {
emit('open-sheet')
}
}
const items = computed(() => [
[{ type: 'label' as const, label: props.username }],
[
{
type: 'label' as const,
label: name.value,
slot: 'account-header' as const,
class: 'mb-1 border-b border-default px-2 pt-2 pb-2.5',
},
{
label: 'Profile',
icon: 'i-lucide-circle-user',
class: onProfile.value ? 'bg-elevated' : undefined,
onSelect: goToProfile,
},
],
colorModeOptions.map((option) => ({
type: 'checkbox' as const,
label: option.label,
@@ -51,6 +73,7 @@ const items = computed(() => [
[
{
label: 'Log out',
icon: 'i-lucide-log-out',
onSelect: () => emit('logout'),
},
],
@@ -58,17 +81,21 @@ const items = computed(() => [
</script>
<template>
<UDropdownMenu :items="items" :content="{ side: 'top', align: 'start' }">
<UDropdownMenu
:open="open"
:items="items"
:content="{ side: 'top', align: 'start' }"
:ui="{ content: 'w-56 p-1' }"
@update:open="onUpdateOpen"
>
<UButton
color="neutral"
variant="ghost"
block
:class="[
'h-10 gap-2.5 px-1.5 font-normal text-highlighted',
collapsed ? 'justify-center' : 'justify-start',
]"
title="Account"
:class="chipClass"
:trailing-icon="collapsed ? undefined : 'i-lucide-chevrons-up-down'"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-default' }"
:ui="{ trailingIcon: 'ms-auto size-3.5 text-current' }"
:aria-label="triggerAriaLabel"
>
<template #leading>
@@ -78,12 +105,18 @@ const items = computed(() => [
size="sm"
:ui="{
root: 'size-6.5 bg-secondary-300',
fallback:
'text-[0.6875rem] font-semibold text-primary-900',
fallback: 'text-[0.6875rem] font-semibold text-primary-900',
}"
/>
</template>
<span v-if="!collapsed" class="truncate">{{ username }}</span>
<span v-if="!collapsed" class="truncate font-medium">{{ name }}</span>
</UButton>
<template #account-header>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="truncate text-sm font-semibold text-highlighted">{{ name }}</span>
<span class="truncate text-xs font-normal text-muted">{{ email }}</span>
</span>
</template>
</UDropdownMenu>
</template>
@@ -15,16 +15,8 @@ describe('Below the warning threshold', () => {
const wrapper = mountCounter(4318)
expect(wrapper.text()).toContain('4 318 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(false)
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(false)
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(false)
})
})
@@ -33,11 +25,7 @@ describe('Near the limit — 90% or more', () => {
const wrapper = mountCounter(9240)
expect(wrapper.text()).toContain('9 240 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-warning-icon"]')
.exists(),
).toBe(true)
expect(wrapper.find('[data-testid="character-counter-warning-icon"]').exists()).toBe(true)
expect(wrapper.text()).not.toContain('characters over')
})
})
@@ -47,13 +35,31 @@ describe('Too long — over the max', () => {
const wrapper = mountCounter(10214)
expect(wrapper.text()).toContain('10 214 / 10 000')
expect(
wrapper
.find('[data-testid="character-counter-over-icon"]')
.exists(),
).toBe(true)
expect(wrapper.find('[data-testid="character-counter-over-icon"]').exists()).toBe(true)
expect(wrapper.text()).toContain(
'214 characters over. Shorten the text or remove an image. Formatting counts too.',
)
})
})
describe('The field variant is a bare mono count', () => {
function mountField(value: number) {
return mount(CharacterCounter, {
props: { value, max: 60, variant: 'field' },
global: { plugins: [uiPlugin] },
})
}
it.each([
[0, 'text-muted'],
[60, 'text-muted'],
[61, 'text-error'],
])('shows %i / 60 with the %s color and no icon or message', (value, colorClass) => {
const wrapper = mountField(value)
expect(wrapper.text()).toBe(`${value} / 60`)
expect(wrapper.classes()).toContain(colorClass)
expect(wrapper.classes()).toContain('font-mono')
expect(wrapper.find('svg').exists()).toBe(false)
})
})
@@ -1,10 +1,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import DashboardShell from '../DashboardShell.vue'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -14,10 +15,27 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
}
})
async function mountShell(path: string, username = 'marta') {
interface ShellOptions {
username?: string
displayName?: string
email?: string
width?: number
}
let mountedShell: VueWrapper | null = null
async function mountShell(path: string, options: ShellOptions = {}) {
const {
username = 'marta',
displayName = '',
email = 'marta@renew.example',
width = DESKTOP_WIDTH,
} = options
localStorage.clear()
setViewportWidth(width)
setActivePinia(createPinia())
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username }),
getMe: vi.fn().mockResolvedValue({ verified: true, username, email, displayName }),
request: vi.fn().mockResolvedValue(undefined),
})
await router.push(path)
@@ -27,11 +45,14 @@ async function mountShell(path: string, username = 'marta') {
plugins: [router, uiPlugin],
},
})
mountedShell = wrapper
await flushPromises()
return wrapper
}
afterEach(async () => {
mountedShell?.unmount()
mountedShell = null
await router.push('/login')
document.body.innerHTML = ''
})
@@ -166,3 +187,112 @@ describe('Collapsed sidebar hides the wordmark and username text but keeps an ac
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
})
describe('The account chip shows the display name and no item is current on /profile', () => {
it('shows the display name and its initials in the sidebar chip', async () => {
await mountShell('/home', { displayName: 'Marta Alves' })
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).toContain('Marta Alves')
expect(footer.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
})
it('keeps the collapsed accessible name on the display name', async () => {
await mountShell('/home', { displayName: 'Marta Alves' })
const collapseButton = document.body.querySelector(
'[data-testid="sidebar-collapse"]',
) as HTMLButtonElement
collapseButton.click()
await flushPromises()
const trigger = document.body.querySelector('[data-slot="footer"] button[aria-haspopup]')
expect(trigger?.getAttribute('aria-label')).toBe('Marta Alves — account menu')
})
it('marks none of the four navigation items current on /profile', async () => {
const wrapper = await mountShell('/profile')
const links = wrapper.findAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
links.forEach((link) => expect(link.attributes('aria-current')).toBeUndefined())
})
})
describe('Account menu: opened from the drawer on mobile', () => {
it('closes the drawer and opens a bottom sheet with a grab handle, the header and 48 px items', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
toggle.click()
await flushPromises()
const accountRow = document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
expect(accountRow).not.toBeNull()
accountRow.click()
await flushPromises()
const sheets = Array.from(document.body.querySelectorAll('[role="dialog"]'))
expect(sheets).toHaveLength(1)
const menu = document.body.querySelector('[role="menu"]') as HTMLElement
expect(
menu.querySelector('[aria-hidden="true"].w-9.h-1, [aria-hidden="true"].h-1'),
).not.toBeNull()
expect(menu.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(menu.textContent).toContain('marta@renew.example')
const labels = Array.from(
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
).map((item) => item.textContent?.trim())
expect(labels).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
menu.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]').forEach((item) =>
expect(item.className).toContain('h-12'),
)
})
it('navigates to /profile and closes the sheet from Profile', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
await flushPromises()
;(
document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
).click()
await flushPromises()
const profile = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
profile.click()
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.querySelector('[role="menu"]')).toBeNull()
})
it('logs out from the sheet', async () => {
await mountShell('/home', { width: MOBILE_WIDTH })
;(document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement).click()
await flushPromises()
;(
document.body.querySelector(
'[role="dialog"] button[aria-haspopup]',
) as HTMLButtonElement
).click()
await flushPromises()
const logOut = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menu"] [role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Log out')!
logOut.click()
await flushPromises()
expect(vi.mocked(useApi)().request).toHaveBeenCalledWith(
'/v1/auth/logout',
expect.objectContaining({ method: 'POST' }),
)
expect(router.currentRoute.value.path).toBe('/login')
})
})
+260 -131
View File
@@ -1,175 +1,304 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import { useSessionStore } from '@/stores/session'
import router from '@/router'
import UserMenu from '../UserMenu.vue'
import { useColorMode } from '@/composables/useColorMode'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
function mountMenu(props: { username: string; collapsed: boolean }) {
interface MenuProps {
username: string
displayName?: string
email?: string
collapsed: boolean
}
const baseProps: MenuProps = {
username: 'marta',
displayName: '',
email: 'marta@renew.example',
collapsed: false,
}
async function mountMenu(props: Partial<MenuProps> = {}, path = '/home') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.status = 'ready'
await router.push(path)
return mount(UserMenu, {
attachTo: document.body,
props,
global: {
plugins: [uiPlugin],
},
props: { ...baseProps, ...props },
global: { plugins: [router, uiPlugin] },
})
}
function findTriggerButton() {
return document.body.querySelector('button')
function chip() {
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
}
async function openMenu() {
findTriggerButton()?.click()
chip().click()
await flushPromises()
}
function findMenuItemsByRole(role: string) {
return Array.from(document.body.querySelectorAll(`[role="${role}"]`))
function menu() {
return document.body.querySelector('[role="menu"]') as HTMLElement | null
}
function findCheckboxItem(label: string) {
return findMenuItemsByRole('menuitemcheckbox').find(
(el) => el.textContent?.trim() === label,
)
function itemsIn(container: ParentNode) {
return Array.from(
container.querySelectorAll('[role="menuitem"], [role="menuitemcheckbox"]'),
) as HTMLElement[]
}
function itemLabels() {
return itemsIn(menu()!).map((el) => el.textContent?.trim())
}
function findItem(label: string) {
return itemsIn(menu()!).find((el) => el.textContent?.trim() === label) as HTMLElement
}
function activate(element: HTMLElement) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
beforeEach(() => {
localStorage.clear()
document.documentElement.classList.remove('dark', 'light')
setViewportWidth(DESKTOP_WIDTH)
})
afterEach(() => {
afterEach(async () => {
document.body.innerHTML = ''
await router.push('/login')
})
describe("UserMenu's dropdown lists the account label, color-mode checkboxes, then Log out", () => {
it('shows avatar initials MA, the username, and the three ordered groups', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false })
describe('The chip opens the account menu instead of logging out', () => {
it('exposes aria-haspopup, aria-expanded, the title and the name, and opens on activation', async () => {
const wrapper = await mountMenu()
const avatar = document.body.querySelector('[data-slot="fallback"]')
expect(avatar?.textContent).toBe('MA')
expect(avatar?.getAttribute('aria-hidden')).toBe('true')
expect(wrapper.text()).toContain('marta')
expect(chip().getAttribute('aria-haspopup')).toBe('menu')
expect(chip().getAttribute('aria-expanded')).toBe('false')
expect(chip().getAttribute('title')).toBe('Account')
expect(chip().textContent).toContain('marta')
expect(wrapper.emitted('logout')).toBeUndefined()
await openMenu()
const groups = findMenuItemsByRole('group')
expect(groups.length).toBe(3)
expect(groups[0].textContent).toContain('marta')
const checkboxLabels = findMenuItemsByRole('menuitemcheckbox').map(
(el) => el.textContent?.trim(),
)
expect(checkboxLabels).toEqual(['System', 'Light', 'Dark'])
expect(groups[2].textContent?.trim()).toBe('Log out')
wrapper.unmount()
expect(chip().getAttribute('aria-expanded')).toBe('true')
expect(menu()).not.toBeNull()
expect(wrapper.emitted('logout')).toBeUndefined()
})
})
it('derives initials from the first letter of the first two words for a multi-word username', () => {
const wrapper = mountMenu({ username: 'jane doe', collapsed: false })
const avatar = document.body.querySelector('[data-slot="fallback"]')
expect(avatar?.textContent).toBe('JD')
wrapper.unmount()
})
it('checks exactly the item matching the current color-mode preference', async () => {
const colorMode = useColorMode()
colorMode.preference = 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
describe('Account menu: opened on desktop', () => {
it('lists the header, Profile, System, Light, Dark and Log out with a divider between its three groups', async () => {
await mountMenu()
await openMenu()
expect(findCheckboxItem('Dark')?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem('Light')?.getAttribute('aria-checked')).toBe(
'false',
)
expect(findCheckboxItem('System')?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
})
})
describe('Selecting a color-mode item updates the preference and its checked state', () => {
it.each([
['system', 'light'],
['light', 'dark'],
['dark', 'system'],
])('moves from %s to %s', async (from, to) => {
const colorMode = useColorMode()
colorMode.preference = from as 'system' | 'light' | 'dark'
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
const label = to.charAt(0).toUpperCase() + to.slice(1)
const fromLabel = from.charAt(0).toUpperCase() + from.slice(1)
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('pointerup', { bubbles: true }),
)
findCheckboxItem(label)?.dispatchEvent(
new MouseEvent('click', { bubbles: true }),
)
await flushPromises()
expect(colorMode.preference).toBe(to)
await openMenu()
expect(findCheckboxItem(label)?.getAttribute('aria-checked')).toBe(
'true',
)
expect(findCheckboxItem(fromLabel)?.getAttribute('aria-checked')).toBe(
'false',
)
wrapper.unmount()
})
})
describe('A successful logout clears the session and navigates to /login', () => {
it('emits logout when the user activates Log out', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: false })
await openMenu()
const logOutItem = Array.from(
document.body.querySelectorAll('[role="menuitem"]'),
).find((el) => el.textContent?.trim() === 'Log out')
;(logOutItem as HTMLElement)?.click()
await flushPromises()
expect(wrapper.emitted('logout')).toBeTruthy()
wrapper.unmount()
})
})
describe('Collapsed sidebar hides the wordmark and username text but keeps an accessible name', () => {
it('hides the visible username but keeps it in the accessible name, and opens the same dropdown', async () => {
const wrapper = mountMenu({ username: 'marta', collapsed: true })
expect(wrapper.text()).not.toContain('marta')
expect(findTriggerButton()?.getAttribute('aria-label')).toBe(
'marta — account menu',
)
const header = menu()!.querySelector('[role="group"]')!
expect(header.textContent).toContain('marta')
expect(header.textContent).toContain('marta@renew.example')
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
expect(
document.body.querySelector('[data-slot="fallback"]')?.textContent,
).toBe('MA')
menu()!.querySelector('[data-slot="viewport"]')!.classList.contains('divide-y'),
).toBe(true)
expect(menu()!.querySelectorAll('[role="group"]')).toHaveLength(3)
})
it('shows the display name in the header and the chip when there is one', async () => {
await mountMenu({ displayName: 'Marta Alves' })
await openMenu()
expect(chip().textContent).toContain('Marta Alves')
expect(menu()!.textContent).toContain('Marta Alves')
expect(menu()!.textContent).toContain('marta@renew.example')
})
it('does not highlight Profile away from /profile', async () => {
await mountMenu()
await openMenu()
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(false)
})
it('gives the chip the sand fill only while the menu is open', async () => {
await mountMenu()
expect(chip().className).not.toContain('bg-secondary-200')
await openMenu()
const groups = findMenuItemsByRole('group')
expect(groups.length).toBe(3)
expect(groups[0].textContent).toContain('marta')
expect(chip().className).toContain('bg-secondary-200')
expect(chip().className).toContain('text-primary-900')
})
})
wrapper.unmount()
describe('The chip is sand while on /profile even with the menu closed', () => {
it('keeps the sand fill on /profile and drops it elsewhere', async () => {
await mountMenu({}, '/profile')
expect(chip().className).toContain('bg-secondary-200')
expect(chip().getAttribute('aria-expanded')).toBe('false')
await router.push('/home')
await flushPromises()
expect(chip().className).not.toContain('bg-secondary-200')
})
})
describe('Closing the menu', () => {
it('closes on Escape and returns focus to the chip', async () => {
await mountMenu()
await openMenu()
menu()!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushPromises()
expect(menu()).toBeNull()
expect(chip().getAttribute('aria-expanded')).toBe('false')
expect(document.activeElement).toBe(chip())
})
it('closes on a click outside', async () => {
await mountMenu()
await openMenu()
document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }))
await flushPromises()
expect(menu()).toBeNull()
expect(chip().getAttribute('aria-expanded')).toBe('false')
})
it('moves between items with the arrow keys', async () => {
await mountMenu()
await openMenu()
const first = itemsIn(menu()!)[0]!
first.focus()
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
await flushPromises()
expect(document.activeElement).toBe(itemsIn(menu()!)[1])
document.activeElement?.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }),
)
await flushPromises()
expect(document.activeElement).toBe(itemsIn(menu()!)[0])
})
})
describe('Menu items', () => {
it('navigates to /profile and closes the menu from Profile', async () => {
await mountMenu()
await openMenu()
activate(findItem('Profile'))
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(menu()).toBeNull()
})
it('highlights Profile on /profile and only closes when activated there', async () => {
await mountMenu({}, '/profile')
await openMenu()
expect(findItem('Profile').classList.contains('bg-elevated')).toBe(true)
activate(findItem('Profile'))
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(menu()).toBeNull()
})
it('emits logout from Log out', async () => {
const wrapper = await mountMenu()
await openMenu()
activate(findItem('Log out'))
await flushPromises()
expect(wrapper.emitted('logout')).toHaveLength(1)
})
})
describe('Theme choice stays in the menu', () => {
it('checks the current preference, sets Dark, closes, and shows Dark checked on reopening', async () => {
const colorMode = useColorMode()
colorMode.preference = 'system'
await mountMenu()
await openMenu()
expect(findItem('System').getAttribute('aria-checked')).toBe('true')
activate(findItem('Dark'))
await flushPromises()
expect(colorMode.preference).toBe('dark')
expect(menu()).toBeNull()
await openMenu()
expect(findItem('Dark').getAttribute('aria-checked')).toBe('true')
expect(findItem('System').getAttribute('aria-checked')).toBe('false')
})
})
describe('Avatar initials', () => {
it.each([
['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'],
])('shows %s / %j as %s and the name %s', async (username, displayName, initials, shown) => {
await mountMenu({ username, displayName })
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe(initials)
expect(chip().textContent).toContain(shown)
await openMenu()
expect(menu()!.textContent).toContain(shown)
})
})
describe('The collapsed rail keeps the menu and an accessible name', () => {
it('shows only the avatar, names the chip and opens the same menu', async () => {
await mountMenu({ displayName: 'Marta Alves', collapsed: true })
expect(chip().textContent).not.toContain('Marta Alves')
expect(chip().getAttribute('aria-label')).toBe('Marta Alves — account menu')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
await openMenu()
expect(itemLabels()).toEqual(['Profile', 'System', 'Light', 'Dark', 'Log out'])
})
})
describe('Below lg the chip hands the menu to the bottom sheet', () => {
it('emits open-sheet instead of opening the popover', async () => {
setViewportWidth(MOBILE_WIDTH)
const wrapper = await mountMenu()
await openMenu()
expect(wrapper.emitted('open-sheet')).toHaveLength(1)
expect(menu()).toBeNull()
})
})
+30
View File
@@ -0,0 +1,30 @@
const REM_PX = 16
function widthLimit(query: string, feature: 'min-width' | 'max-width') {
const match = query.match(new RegExp(`\\(${feature}:\\s*([\\d.]+)(rem|px)\\)`))
if (!match) {
return null
}
return Number(match[1]) * (match[2] === 'rem' ? REM_PX : 1)
}
export function setViewportWidth(width: number) {
window.matchMedia = ((query: string) => {
const min = widthLimit(query, 'min-width')
const max = widthLimit(query, 'max-width')
const matches = (min === null || width >= min) && (max === null || width <= max)
return {
matches,
media: query,
onchange: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
addListener: () => undefined,
removeListener: () => undefined,
dispatchEvent: () => false,
}
}) as unknown as typeof window.matchMedia
}
export const DESKTOP_WIDTH = 1440
export const MOBILE_WIDTH = 390
+45 -15
View File
@@ -5,11 +5,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { ApiError, NetworkError, isOffline, useApi } from '@/composables/useApi'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -22,10 +18,7 @@ function setOnline(value: boolean) {
Object.defineProperty(navigator, 'onLine', { configurable: true, value })
}
async function expectApiError(
call: Promise<unknown>,
matcher: Record<string, unknown>,
) {
async function expectApiError(call: Promise<unknown>, matcher: Record<string, unknown>) {
await expect(call).rejects.toBeInstanceOf(ApiError)
await expect(call).rejects.toMatchObject(matcher)
}
@@ -109,9 +102,7 @@ describe('useApi global 401/403/429/offline handling', () => {
await expectApiError(call, { status: 403 })
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register/pending'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
expect(session.session).toEqual({ verified: false })
})
@@ -159,9 +150,7 @@ describe('useApi global 401/403/429/offline handling', () => {
})
it('A 429 on a rate-limited endpoint exposes the Retry-After seconds', async () => {
fetchMock.mockResolvedValue(
mockResponse(429, null, { 'Retry-After': '30' }),
)
fetchMock.mockResolvedValue(mockResponse(429, null, { 'Retry-After': '30' }))
const call = useApi().request('/v1/auth/login', { method: 'POST' })
@@ -209,3 +198,44 @@ describe('useApi global 401/403/429/offline handling', () => {
expect(isOffline.value).toBe(false)
})
})
describe('getMe maps the account fields', () => {
it('maps email and display_name onto email and displayName', async () => {
fetchMock.mockResolvedValue(
mockResponse(200, {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
display_name: 'Marta Alves',
}),
)
await expect(useApi().getMe()).resolves.toEqual({
id: 1,
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: 'Marta Alves',
})
})
it.each([
['absent', undefined],
['null', null],
])('reads a %s display_name as an empty string', async (_label, value) => {
fetchMock.mockResolvedValue(
mockResponse(200, {
id: 1,
username: 'marta',
email: 'marta@renew.example',
is_verified: true,
display_name: value,
}),
)
const me = await useApi().getMe()
expect(me.displayName).toBe('')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useColorMode, type ColorModePreference } from '@/composables/useColorMode'
import { accountInitials, accountName } from '@/lib/accountName'
const PROFILE_PATH = '/profile'
export const colorModeOptions: { label: string; value: ColorModePreference }[] = [
{ label: 'System', value: 'system' },
{ label: 'Light', value: 'light' },
{ label: 'Dark', value: 'dark' },
]
export function useAccountMenu(account: () => { username: string; displayName?: string }) {
const route = useRoute()
const router = useRouter()
const colorMode = useColorMode()
const name = computed(() => accountName(account().username, account().displayName))
const initials = computed(() => accountInitials(account().username, account().displayName))
const onProfile = computed(() => route.path === PROFILE_PATH)
function goToProfile() {
if (!onProfile.value) {
router.push(PROFILE_PATH)
}
}
return { colorMode, name, initials, onProfile, goToProfile }
}
+16 -5
View File
@@ -25,6 +25,8 @@ export interface MeResult {
id?: number
verified: boolean
username?: string
email?: string
displayName: string
}
export type OnUnauthorized = 'redirect' | 'passthrough'
@@ -127,11 +129,20 @@ export function useApi() {
}
async function getMe(): Promise<MeResult> {
const body = await request<{ id?: number; is_verified: boolean; username?: string }>(
'/v1/auth/me',
{ onUnauthorized: 'passthrough' },
)
return { id: body.id, verified: Boolean(body.is_verified), username: body.username }
const body = await request<{
id?: number
is_verified: boolean
username?: string
email?: string
display_name?: string | null
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
return {
id: body.id,
verified: Boolean(body.is_verified),
username: body.username,
email: body.email,
displayName: body.display_name ?? '',
}
}
return { getMe, request }
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { accountInitials, accountName } from '@/lib/accountName'
describe('accountName', () => {
it.each([
['marta', '', 'marta'],
['marta', 'Marta Alves', 'Marta Alves'],
['marta', undefined, 'marta'],
['', '', ''],
])('shows %s with display name %j as %s', (username, display, expected) => {
expect(accountName(username, display)).toBe(expected)
})
})
describe('Avatar initials', () => {
it.each([
['marta', '', 'MA'],
['marta', 'Marta Alves', 'MA'],
['marta', 'Marta Alves Costa', 'MA'],
['ines', 'Inès Duarte', 'ID'],
['elodie', 'Élodie Martin', 'ÉM'],
['marta', 'Marta', 'MA'],
['m.alves', '', 'M.'],
['marta', ' Marta Alves ', 'MA'],
['', '', ''],
])('gives %s with display name %j the initials %s', (username, display, expected) => {
expect(accountInitials(username, display)).toBe(expected)
})
it('counts an emoji as one character', () => {
expect(accountInitials('x', '😀 Smile')).toBe('😀S')
})
})
+22
View File
@@ -0,0 +1,22 @@
const WHITESPACE = /\s+/
function firstCharacters(value: string, count: number) {
return Array.from(value).slice(0, count).join('')
}
export function accountName(username: string, displayName?: string | null) {
return displayName ? displayName : username
}
export function accountInitials(username: string, displayName?: string | null) {
const words = (displayName ?? '').trim().split(WHITESPACE).filter(Boolean)
if (words.length >= 2) {
return words
.slice(0, 2)
.map((word) => firstCharacters(word, 1))
.join('')
.toUpperCase()
}
const source = words[0] ?? username
return firstCharacters(source, 2).toUpperCase()
}
+5
View File
@@ -76,6 +76,11 @@ const router = createRouter({
component: () => import('@/views/UsersView.vue'),
meta: { auth: 'verified', title: 'Users' },
},
{
path: '/profile',
component: () => import('@/views/ProfileView.vue'),
meta: { auth: 'verified', title: 'Profile' },
},
{
path: '/integration',
component: () => import('@/views/IntegrationView.vue'),
+28
View File
@@ -18,3 +18,31 @@ describe('session store clear action', () => {
expect(session.status).toBe('ready')
})
})
describe('session store setDisplayName action', () => {
it('replaces the session display name with the given value', () => {
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName: '' }
session.setDisplayName('Marta Alves')
expect(session.session?.displayName).toBe('Marta Alves')
})
it.each([[null], [undefined]])('reads %s as an empty display name', (value) => {
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName: 'Marta Alves' }
session.setDisplayName(value)
expect(session.session?.displayName).toBe('')
})
it('does nothing without a session', () => {
const session = useSessionStore()
session.setDisplayName('Marta Alves')
expect(session.session).toBeNull()
})
})
+9 -1
View File
@@ -8,6 +8,8 @@ export interface Session {
id?: number
verified: boolean
username?: string
email?: string
displayName: string
}
export const useSessionStore = defineStore('session', () => {
@@ -53,5 +55,11 @@ export const useSessionStore = defineStore('session', () => {
status.value = 'ready'
}
return { status, session, check, retry, clear }
function setDisplayName(displayName: string | null | undefined) {
if (session.value) {
session.value = { ...session.value, displayName: displayName ?? '' }
}
}
return { status, session, check, retry, clear, setDisplayName }
})
+3 -2
View File
@@ -7,6 +7,7 @@ import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import { useArticles, type Article } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session'
import { accountName } from '@/lib/accountName'
import { formatRelativeDate } from '@/lib/relativeDate'
const SKELETON_DELAY_MS = 150
@@ -37,8 +38,8 @@ function greetingForHour(hour: number) {
const greeting = computed(() => {
const timeOfDayGreeting = greetingForHour(new Date().getHours())
const username = session.session?.username
return username ? `${timeOfDayGreeting}, ${username}` : timeOfDayGreeting
const name = accountName(session.session?.username ?? '', session.session?.displayName)
return name ? `${timeOfDayGreeting}, ${name}` : timeOfDayGreeting
})
const subtitle = computed(() =>
+398
View File
@@ -0,0 +1,398 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useOverlay, useToast } from '@nuxt/ui/composables'
import CharacterCounter from '@/components/CharacterCounter.vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
import ProfileCard from '@/components/ProfileCard.vue'
import { ApiError, authRedirectPending, useApi } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { useSessionStore, type Session } from '@/stores/session'
const DISPLAY_NAME_MAX = 60
const DISPLAY_NAME_LIMIT_BODY = 'Display name must be 60 characters or fewer.'
const DISPLAY_NAME_LIMIT_COPY = 'Use 60 characters or fewer.'
const SAVE_FAILED_COPY =
"Couldn't save your display name. Your text is still here. Try again in a moment."
const RESET_FALLBACK_SECONDS = 300
const PASSWORD_MASK = '••••••••••••'
const SKELETON_CARDS = [
{ titleWidth: 'w-30', inputs: 1 },
{ titleWidth: 'w-22.5', inputs: 2 },
{ titleWidth: 'w-25', inputs: 1 },
]
type ResetState = 'idle' | 'sending' | 'sent' | 'limited' | 'error'
const session = useSessionStore()
const toast = useToast()
const overlay = useOverlay()
const { request } = useApi()
const retryAfter = useRetryAfter()
const account = ref<Session | null>(session.session)
const field = ref('')
const saving = ref(false)
const saveFailed = ref(false)
const serverLimitError = ref(false)
const resetState = ref<ResetState>('idle')
const inputRef = ref<{ inputRef?: HTMLInputElement } | null>(null)
watch(
() => session.session,
(value) => {
if (value) {
account.value = value
}
},
)
watch(
account,
(value, previous) => {
if (value && !previous) {
field.value = value.displayName ?? ''
}
},
{ immediate: true },
)
watch(field, () => {
serverLimitError.value = false
})
watch(retryAfter.remaining, (value) => {
if (value === 0 && resetState.value === 'limited') {
resetState.value = 'idle'
}
})
const username = computed(() => account.value?.username ?? '')
const email = computed(() => account.value?.email ?? '')
const savedName = computed(() => account.value?.displayName ?? '')
const draft = computed(() => field.value.trim())
const count = computed(() => Array.from(draft.value).length)
const dirty = computed(() => draft.value !== savedName.value)
const tooLong = computed(() => count.value > DISPLAY_NAME_MAX)
const showFieldError = computed(() => tooLong.value || serverLimitError.value)
const saveEnabled = computed(() => dirty.value && !showFieldError.value)
const showUnsaved = computed(() => dirty.value && !saving.value)
const statusText = computed(() => {
if (showUnsaved.value) {
return 'Unsaved changes'
}
return savedName.value
? `You appear as “${savedName.value}”.`
: `You appear as “${username.value}”, your username.`
})
const inputUi = { base: 'h-11 text-lg lg:h-10 lg:text-base' }
const readOnlyUi = { base: 'h-11 text-lg disabled:text-toned lg:h-10 lg:text-base' }
const readOnlyLockUi = { trailingIcon: 'size-3.5 text-dimmed' }
const resetNotes = {
sent: {
color: 'success',
icon: 'i-lucide-mail-check',
title: 'Reset link sent',
},
limited: {
color: 'warning',
icon: 'i-lucide-clock-alert',
title: 'Too many requests',
description: "You've asked for several links recently. Wait a few minutes, then try again.",
},
error: {
color: 'error',
icon: 'i-lucide-circle-x',
title: "Couldn't send the link",
description: 'Check your connection and try again.',
},
} as const
const resetNote = computed(() => {
if (resetState.value === 'sent') {
return {
...resetNotes.sent,
description: `Check ${email.value}. The link works once, within 1 hour.`,
}
}
if (resetState.value === 'limited' || resetState.value === 'error') {
return resetNotes[resetState.value]
}
return null
})
const resetButtonLabel = computed(() => {
if (resetState.value === 'sending') {
return 'Sending…'
}
return resetState.value === 'sent' ? 'Send again' : 'Send reset link'
})
const resetBusy = computed(() => resetState.value === 'sending')
const resetDisabled = computed(() => resetBusy.value || resetState.value === 'limited')
function focusField() {
nextTick(() => inputRef.value?.inputRef?.focus())
}
function announceSaved(displayName: string) {
toast.add({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: displayName ? 'Display name saved' : 'Display name removed',
description: displayName
? `You now appear as ${displayName} across RenewCMS and in emails.`
: `You now appear as ${username.value}, your username.`,
})
}
function isHandledGlobally(error: unknown) {
return error instanceof ApiError && (error.status === 401 || error.status === 403)
}
async function onSave() {
if (saving.value || !saveEnabled.value) {
return
}
saving.value = true
saveFailed.value = false
serverLimitError.value = false
try {
const updated = await request<{ display_name?: string | null }>('/v1/user/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ display_name: draft.value }),
})
const displayName = updated.display_name ?? ''
session.setDisplayName(displayName)
field.value = displayName
announceSaved(displayName)
} catch (error) {
if (isHandledGlobally(error)) {
return
}
if (
error instanceof ApiError &&
error.status === 400 &&
error.body === DISPLAY_NAME_LIMIT_BODY
) {
serverLimitError.value = true
} else {
saveFailed.value = true
}
focusField()
} finally {
saving.value = false
}
}
async function onSendReset() {
if (resetDisabled.value) {
return
}
resetState.value = 'sending'
try {
await request('/v1/auth/password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.value }),
})
resetState.value = 'sent'
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
retryAfter.start(error.retryAfter || RESET_FALLBACK_SECONDS)
resetState.value = 'limited'
return
}
resetState.value = isHandledGlobally(error) ? 'idle' : 'error'
}
}
onBeforeRouteLeave(async () => {
if (authRedirectPending.value || !dirty.value) {
return true
}
const keepEditing = await overlay
.create(ConfirmDialog, {
props: {
title: 'Leave without saving?',
description: "Your display name change hasn't been saved.",
confirmLabel: 'Keep editing',
cancelLabel: 'Discard changes',
pendingLabel: 'Keep editing',
tone: 'neutral',
action: async () => {},
dismissResolvesTo: 'confirm',
},
})
.open()
return !keepEditing
})
</script>
<template>
<DashboardShell render-while-checking>
<div class="flex max-w-164 flex-col gap-5">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Profile</h1>
<p class="text-muted">Your account on this RenewCMS workspace.</p>
</div>
<div v-if="!account" aria-busy="true" class="flex flex-col gap-4">
<div
v-for="(card, cardIndex) in SKELETON_CARDS"
:key="cardIndex"
data-testid="profile-skeleton-card"
class="flex flex-col gap-3 border border-default bg-default p-4 lg:p-5"
>
<USkeleton class="h-3.5 rounded-none bg-accented" :class="card.titleWidth" />
<USkeleton
v-for="inputIndex in card.inputs"
:key="inputIndex"
class="h-10 rounded-none bg-accented"
/>
</div>
</div>
<template v-else>
<UForm :state="{ displayName: field }" @submit="onSave">
<ProfileCard
title="Display name"
description="Shown instead of your username across the back-office and in the emails RenewCMS sends. Leave it empty to use your username."
>
<UAlert
v-if="saveFailed"
role="alert"
color="error"
variant="soft"
icon="i-lucide-circle-x"
:description="SAVE_FAILED_COPY"
:close="false"
:ui="{ root: 'gap-2 px-3 py-2.5' }"
/>
<UFormField name="displayName" :error="showFieldError">
<template #label>
Display name <span class="font-normal text-muted">(optional)</span>
</template>
<UInput
ref="inputRef"
v-model="field"
placeholder="e.g. Marta Alves"
autocomplete="off"
:disabled="saving"
class="w-full"
:ui="inputUi"
@keydown.enter.prevent="onSave"
/>
<div class="mt-1.5 flex items-start gap-3">
<span
v-if="showFieldError"
data-testid="display-name-error"
class="flex flex-1 items-center gap-1.5 text-xs/5 text-error"
>
<UIcon name="i-lucide-circle-alert" class="size-3.5 shrink-0" />
{{ DISPLAY_NAME_LIMIT_COPY }}
</span>
<span v-else class="flex-1" />
<CharacterCounter
data-testid="display-name-counter"
variant="field"
:value="count"
:max="DISPLAY_NAME_MAX"
/>
</div>
</UFormField>
<template #footer>
<span
data-testid="display-name-status"
class="min-w-0 flex-1 text-xs/[18px]"
:class="showUnsaved ? 'text-warning' : 'text-muted'"
>{{ statusText }}</span
>
<UButton
type="submit"
:loading="saving"
:disabled="!saveEnabled && !saving"
class="h-11 shrink-0 px-3.5 lg:h-8"
>
{{ saving ? 'Saving…' : 'Save' }}
</UButton>
</template>
</ProfileCard>
</UForm>
<ProfileCard
title="Account"
description="Set when your account was created. You log in with your username, even after adding a display name."
>
<div class="grid gap-3.5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<UFormField label="Username">
<UInput
:model-value="username"
disabled
trailing-icon="i-lucide-lock"
class="w-full"
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
<UFormField label="Email">
<UInput
:model-value="email"
disabled
trailing-icon="i-lucide-lock"
class="w-full"
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
</div>
</ProfileCard>
<ProfileCard
title="Password"
:description="`To change it, we'll email a reset link to ${email}. The link works once, within 1 hour.`"
>
<div class="flex flex-col gap-3 lg:flex-row lg:items-end">
<UFormField label="Password" class="min-w-0 flex-1">
<UInput
:model-value="PASSWORD_MASK"
disabled
trailing-icon="i-lucide-lock"
class="w-full"
:ui="{ ...readOnlyUi, ...readOnlyLockUi }"
/>
</UFormField>
<UButton
color="neutral"
variant="outline"
:icon="resetBusy ? undefined : 'i-lucide-mail'"
:loading="resetBusy"
:disabled="resetDisabled"
class="h-11 justify-center border border-accented bg-default px-3.5 whitespace-nowrap text-highlighted ring-0 lg:h-10"
@click="onSendReset"
>
{{ resetButtonLabel }}
</UButton>
</div>
<UAlert
v-if="resetNote"
data-testid="reset-note"
role="status"
variant="soft"
:color="resetNote.color"
:icon="resetNote.icon"
:title="resetNote.title"
:description="resetNote.description"
:close="false"
/>
</ProfileCard>
</template>
</div>
</DashboardShell>
</template>
+52
View File
@@ -379,6 +379,58 @@ describe('The greeting is identical across every load state at a fixed hour', ()
})
})
describe('The greeting names the person by display name, else username', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it.each([
{ displayName: '', name: 'marta' },
{ displayName: 'Marta Alves', name: 'Marta Alves' },
])('reads the name "$name" for display name "$displayName"', async ({ displayName, name }) => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
expect(wrapper.find('[data-testid="greeting"]').text()).toBe(`Good morning, ${name}`)
})
it('shows a display name saved after the page was mounted without a reload', async () => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName: '' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/': () => mockResponse(200, sixArticles),
}) as unknown as typeof fetch
await router.push('/home')
const wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
currentWrapper = wrapper
await flushPromises()
session.setDisplayName('Marta Alves')
await flushPromises()
expect(wrapper.find('[data-testid="greeting"]').text()).toBe('Good morning, Marta Alves')
})
})
describe('The greeting handles a missing username', () => {
it('renders the plain greeting without a stray comma when the session has no username', async () => {
vi.useFakeTimers()
+13 -37
View File
@@ -6,11 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import PendingView from '../PendingView.vue'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -40,8 +34,7 @@ function fetchRouter(
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn(
(_component: unknown, options: { props: Record<string, unknown> }) => {
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
@@ -49,8 +42,7 @@ function createOverlayMock() {
resolveOpen = resolve
}),
}
},
)
})
return {
create,
getProps: () => props,
@@ -148,24 +140,16 @@ describe('Logging out', () => {
await findButton(wrapper, 'Log out')?.trigger('click')
await flushPromises()
expect((findButton(wrapper, 'Log out')?.element as HTMLButtonElement).disabled).toBe(true)
expect(
(findButton(wrapper, 'Log out')?.element as HTMLButtonElement)
.disabled,
).toBe(true)
expect(
(
findButton(wrapper, 'Cancel registration')
?.element as HTMLButtonElement
).disabled,
(findButton(wrapper, 'Cancel registration')?.element as HTMLButtonElement).disabled,
).toBe(true)
resolveLogout?.()
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
@@ -245,9 +229,7 @@ describe('Cancelled — 200 navigates to /register with a toast', () => {
overlayMock.resolveOpen(true)
await flushPromises()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -275,7 +257,7 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
const props = overlayMock.getProps() as { action: () => Promise<void> }
await props.action()
expect(session.session).toEqual({ verified: true })
expect(session.session).toMatchObject({ verified: true, displayName: '' })
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
overlayMock.resolveOpen(true)
@@ -291,12 +273,9 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
})
expect(router.currentRoute.value.path).not.toBe('/home')
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
},
)
})
expect(toastAdd).toHaveBeenCalledTimes(1)
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
pushSpy.mockRestore()
@@ -310,9 +289,7 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])(
'rejects the action instead of navigating for $name',
async ({ handler }) => {
])('rejects the action instead of navigating for $name', async ({ handler }) => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': handler,
})
@@ -327,6 +304,5 @@ describe('Cancel failed — the dialog stays open and shows the failure inline',
expect(router.currentRoute.value.path).toBe('/register/pending')
expect(toastAdd).not.toHaveBeenCalled()
},
)
})
})
+872
View File
@@ -0,0 +1,872 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import App from '@/App.vue'
import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
let toastAdd: ReturnType<typeof vi.fn>
let overlayMock: ReturnType<typeof createOverlayMock>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useToast: () => ({ add: toastAdd }),
useOverlay: () => overlayMock,
}
})
function createOverlayMock() {
let props: Record<string, unknown> | null = null
let resolveOpen: ((value: unknown) => void) | null = null
const create = vi.fn((_component: unknown, options: { props: Record<string, unknown> }) => {
props = options.props
return {
open: () =>
new Promise((resolve) => {
resolveOpen = resolve
}),
}
})
return {
create,
getProps: () => props,
resolveOpen: (value: unknown) => resolveOpen?.(value),
}
}
const MARTA: Session = {
id: 1,
verified: true,
username: 'marta',
email: 'marta@renew.example',
displayName: '',
}
const DISPLAY_NAME_PATH = '/v1/user/me'
const RESET_PATH = '/v1/auth/password-reset'
const LOGOUT_PATH = '/v1/auth/logout'
const LIMIT_BODY = 'Display name must be 60 characters or fewer.'
const SAVE_FAILED_COPY =
"Couldn't save your display name. Your text is still here. Try again in a moment."
type Handler = (init?: RequestInit) => Promise<unknown>
interface Mounted {
wrapper: VueWrapper
request: ReturnType<typeof vi.fn>
getMe: ReturnType<typeof vi.fn>
}
function deferred<T = unknown>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
interface MountOptions {
session?: Session | null
status?: 'idle' | 'ready'
getMe?: () => Promise<Session>
handlers?: Record<string, Handler>
path?: string
}
let mountedWrapper: VueWrapper | null = null
async function mountApp(options: MountOptions = {}): Promise<Mounted> {
const { session = MARTA, status = 'ready', handlers = {}, path = '/profile' } = options
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
const store = useSessionStore()
store.session = session ? { ...session } : null
store.status = status
const request = vi.fn((requestPath: string, init?: RequestInit) => {
const key = `${init?.method ?? 'GET'} ${requestPath}`
const handler = handlers[key]
if (handler) {
return handler(init)
}
if (requestPath === LOGOUT_PATH) {
return Promise.resolve(undefined)
}
return Promise.resolve([])
})
const getMe = vi.fn(options.getMe ?? (() => Promise.resolve({ ...MARTA })))
vi.mocked(useApi).mockReturnValue({ getMe, request } as unknown as ReturnType<typeof useApi>)
await router.push(path)
const wrapper = mount(App, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
mountedWrapper = wrapper
await flushPromises()
return { wrapper, request, getMe }
}
function displayNameField() {
return document.body.querySelector('input[placeholder="e.g. Marta Alves"]') as HTMLInputElement
}
async function typeInto(value: string) {
const input = displayNameField()
input.value = value
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
}
function pressEnter() {
displayNameField().dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }),
)
}
function buttonByText(pattern: RegExp) {
return Array.from(document.body.querySelectorAll('button')).find((button) =>
pattern.test(button.textContent?.trim() ?? ''),
) as HTMLButtonElement | undefined
}
function saveButton() {
return buttonByText(/^(Save|Saving…)$/)!
}
function resetButton() {
return buttonByText(/^(Send reset link|Sending…|Send again)$/)!
}
function byTestId(id: string) {
return document.body.querySelector(`[data-testid="${id}"]`) as HTMLElement | null
}
function counter() {
return byTestId('display-name-counter')!
}
function footer() {
return byTestId('display-name-status')!
}
function isEnabled(button: HTMLButtonElement) {
return !button.disabled
}
async function clickSave() {
saveButton().click()
await flushPromises()
}
async function clickReset() {
resetButton().click()
await flushPromises()
}
function patchBodies(request: ReturnType<typeof vi.fn>) {
return request.mock.calls
.filter(([, init]) => (init as RequestInit | undefined)?.method === 'PATCH')
.map(([, init]) => JSON.parse((init as RequestInit).body as string))
}
function callsTo(request: ReturnType<typeof vi.fn>, path: string, method?: string) {
return request.mock.calls.filter(
([requestPath, init]) =>
requestPath === path &&
(method === undefined || (init as RequestInit)?.method === method),
)
}
function chip() {
return document.body.querySelector('button[aria-haspopup]') as HTMLButtonElement
}
function activate(element: HTMLElement) {
element.dispatchEvent(new MouseEvent('pointerup', { bubbles: true }))
element.dispatchEvent(new MouseEvent('click', { bubbles: true }))
}
function savedResponse(displayName: string) {
return Promise.resolve({ id: 1, username: 'marta', display_name: displayName })
}
const NETWORK_ERROR = () => Promise.reject(new NetworkError())
const SERVER_ERROR = () => Promise.reject(new ApiError(500))
beforeEach(() => {
toastAdd = vi.fn()
overlayMock = createOverlayMock()
localStorage.clear()
authRedirectPending.value = false
})
afterEach(async () => {
vi.useRealTimers()
mountedWrapper?.unmount()
mountedWrapper = null
document.body.innerHTML = ''
await router.push('/login')
})
describe('Route guards for /profile', () => {
it('renders ProfilePage for a resolved, verified session', async () => {
await mountApp()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
})
it('redirects a resolved, unverified session to /register/pending', async () => {
await mountApp({ session: { ...MARTA, verified: false } })
await flushPromises()
expect(router.currentRoute.value.path).toBe('/register/pending')
})
it('redirects a 401 from /auth/me to /login?redirect=/profile&reason=expired', async () => {
await mountApp({
session: null,
status: 'idle',
getMe: () => Promise.reject(new ApiError(401)),
})
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
})
})
describe('Default: the usual first visit with no display name', () => {
it('shows the header, the empty field, the account details and the reset button', async () => {
const { request } = await mountApp()
const text = document.body.textContent ?? ''
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
'Profile',
)
expect(document.querySelector('h1')?.textContent?.trim()).toBe('Profile')
expect(text).toContain('Your account on this RenewCMS workspace.')
expect(displayNameField().value).toBe('')
expect(displayNameField().getAttribute('placeholder')).toBe('e.g. Marta Alves')
expect(counter().textContent?.trim()).toBe('0 / 60')
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
expect(isEnabled(saveButton())).toBe(false)
expect(text).toContain('Display name (optional)')
const accountInputs = Array.from(
document.body.querySelectorAll<HTMLInputElement>('input:disabled'),
)
expect(accountInputs.map((input) => input.value)).toEqual([
'marta',
'marta@renew.example',
'••••••••••••',
])
expect(text).toContain(
"To change it, we'll email a reset link to marta@renew.example. The link works once, within 1 hour.",
)
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(byTestId('reset-note')).toBeNull()
expect(request).not.toHaveBeenCalled()
})
it('marks none of the four navigation items as the current page', async () => {
await mountApp()
const links = document.body.querySelectorAll('nav[aria-label="Main"] a')
expect(links).toHaveLength(4)
links.forEach((link) => expect(link.getAttribute('aria-current')).toBeNull())
})
it('places the three cards in order Display name, Account, Password', async () => {
await mountApp()
const titles = Array.from(document.body.querySelectorAll('section h2')).map((el) =>
el.textContent?.trim(),
)
expect(titles).toEqual(['Display name', 'Account', 'Password'])
})
})
describe('Loading: opened by URL before /auth/me has returned', () => {
it('shows the header and three skeleton cards without AppBoot, then the Default state', async () => {
const pending = deferred<Session>()
await mountApp({
session: null,
status: 'idle',
getMe: () => pending.promise,
})
expect(document.querySelector('nav[aria-label="breadcrumb" i]')?.textContent).toContain(
'Profile',
)
expect(document.body.textContent).toContain('Your account on this RenewCMS workspace.')
const busy = document.body.querySelector('[aria-busy="true"]')!
expect(busy.querySelectorAll('[data-testid="profile-skeleton-card"]')).toHaveLength(3)
expect(document.body.querySelector('[aria-label="Loading"]')).toBeNull()
expect(displayNameField()).toBeNull()
pending.resolve({ ...MARTA })
await flushPromises()
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(displayNameField()).not.toBeNull()
expect(counter().textContent?.trim()).toBe('0 / 60')
})
})
describe('Opened from the menu, the cached user renders at once', () => {
it('navigates from /home through the account menu with no skeleton and no second /auth/me', async () => {
const { getMe } = await mountApp({ path: '/home' })
chip().click()
await flushPromises()
const profileItem = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Profile')!
activate(profileItem)
await flushPromises()
expect(router.currentRoute.value.path).toBe('/profile')
expect(document.body.querySelector('[aria-busy="true"]')).toBeNull()
expect(displayNameField()).not.toBeNull()
expect(getMe).not.toHaveBeenCalled()
})
})
describe('Editing: typing turns Save on', () => {
it('shows the count, Unsaved changes in the warning color and an enabled Save, sending nothing', async () => {
const { request } = await mountApp()
await typeInto('Marta Alves')
expect(counter().textContent?.trim()).toBe('11 / 60')
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(footer().classList.contains('text-warning')).toBe(true)
expect(isEnabled(saveButton())).toBe(true)
expect(request).not.toHaveBeenCalled()
})
})
describe('The counter counts Unicode characters after trimming', () => {
it.each([
['"Marta Alves"', 'Marta Alves', 11, 'text-muted', true],
['" Marta Alves "', ' Marta Alves ', 11, 'text-muted', true],
['60 repetitions of "a"', 'a'.repeat(60), 60, 'text-muted', true],
['61 repetitions of "a"', 'a'.repeat(61), 61, 'text-error', false],
['60 repetitions of an emoji', '😀'.repeat(60), 60, 'text-muted', true],
['61 repetitions of an emoji', '😀'.repeat(61), 61, 'text-error', false],
['"Cafe" followed by U+0301', 'Café', 5, 'text-muted', true],
['5 spaces, 60 "a", 5 spaces', ` ${'a'.repeat(60)} `, 60, 'text-muted', true],
])('%s counts %i, %s, Save enabled: %s', async (_label, value, count, colorClass, enabled) => {
await mountApp()
await typeInto(value)
expect(counter().textContent?.trim()).toBe(`${count} / 60`)
expect(counter().classList.contains(colorClass)).toBe(true)
expect(isEnabled(saveButton())).toBe(enabled)
})
})
describe('Too long: over 60 characters shows a field error and keeps Save off', () => {
it('shows the error, ignores Enter and recovers once shortened', async () => {
const { request } = await mountApp()
await typeInto('Marta Alves, Head of Renovations, Brussels and Wallonia office')
expect(counter().textContent?.trim()).toBe('62 / 60')
expect(counter().classList.contains('text-error')).toBe(true)
const error = byTestId('display-name-error')!
expect(error.textContent).toContain('Use 60 characters or fewer.')
expect(error.querySelector('svg, [class*="i-lucide-circle-alert"]')).not.toBeNull()
expect(displayNameField().getAttribute('aria-invalid')).toBe('true')
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(footer().classList.contains('text-warning')).toBe(true)
expect(isEnabled(saveButton())).toBe(false)
pressEnter()
await flushPromises()
expect(patchBodies(request)).toHaveLength(0)
await typeInto('Marta Alves')
expect(byTestId('display-name-error')).toBeNull()
expect(counter().textContent?.trim()).toBe('11 / 60')
expect(isEnabled(saveButton())).toBe(true)
})
})
describe('Dirty is measured on the trimmed draft against the saved name', () => {
it.each([
['', ' ', false, 'You appear as “marta”, your username.'],
['Marta Alves', 'Marta Alves', false, 'You appear as “Marta Alves”.'],
['Marta Alves', 'Marta Alves ', false, 'You appear as “Marta Alves”.'],
['Marta Alves', '', true, 'Unsaved changes'],
['Marta Alves', 'Marta Alves', true, 'Unsaved changes'],
])(
'saved %j and typed %j gives Save enabled: %s and %s',
async (saved, typed, enabled, text) => {
await mountApp({ session: { ...MARTA, displayName: saved } })
await typeInto(typed)
expect(isEnabled(saveButton())).toBe(enabled)
expect(footer().textContent?.trim()).toBe(text)
},
)
})
describe('Enter saves when Save is enabled', () => {
it('sends PATCH /v1/user/me once with the trimmed display name', async () => {
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
pressEnter()
await flushPromises()
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
})
it('does nothing when the draft is not dirty', async () => {
const { request } = await mountApp()
pressEnter()
await flushPromises()
expect(patchBodies(request)).toHaveLength(0)
})
})
describe('Saving: the field is disabled and a second submit is ignored', () => {
it('sends the trimmed value, disables and dims the field, and ignores Enter and a second click', async () => {
const pending = deferred()
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => pending.promise },
})
await typeInto(' Marta Alves ')
await clickSave()
expect(patchBodies(request)).toEqual([{ display_name: 'Marta Alves' }])
expect(displayNameField().disabled).toBe(true)
expect(saveButton().textContent?.trim()).toBe('Saving…')
expect(saveButton().querySelector('[class*="i-lucide-loader-circle"], svg')).not.toBeNull()
expect(footer().textContent?.trim()).not.toBe('Unsaved changes')
pressEnter()
saveButton().click()
await flushPromises()
expect(patchBodies(request)).toHaveLength(1)
})
})
describe('Saved: a 200 updates the session, the chip, the footer and raises a toast', () => {
it('replaces the session name, syncs the field, and raises the success toast', async () => {
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
await clickSave()
expect(useSessionStore().session?.displayName).toBe('Marta Alves')
expect(chip().textContent).toContain('Marta Alves')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(displayNameField().value).toBe('Marta Alves')
expect(displayNameField().disabled).toBe(false)
expect(counter().textContent?.trim()).toBe('11 / 60')
expect(footer().textContent?.trim()).toBe('You appear as “Marta Alves”.')
expect(isEnabled(saveButton())).toBe(false)
expect(toastAdd).toHaveBeenCalledWith({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Display name saved',
description: 'You now appear as Marta Alves across RenewCMS and in emails.',
})
expect(request).toHaveBeenCalledTimes(1)
})
it('opens no leave confirmation when navigating away afterwards', async () => {
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
await clickSave()
await router.push('/users')
await flushPromises()
expect(overlayMock.create).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/users')
})
})
describe("The server's value wins over what was typed", () => {
it('uses the response display_name in the field, the chip and the toast', async () => {
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto(' Marta Alves ')
await clickSave()
expect(displayNameField().value).toBe('Marta Alves')
expect(chip().textContent).toContain('Marta Alves')
expect(toastAdd.mock.calls[0]![0].description).toContain('Marta Alves across RenewCMS')
})
})
describe('Clearing the display name goes back to the username', () => {
it('sends an empty display_name and shows the username with the removed toast', async () => {
const { request } = await mountApp({
session: { ...MARTA, displayName: 'Marta Alves' },
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('') },
})
await typeInto('')
await clickSave()
expect(patchBodies(request)).toEqual([{ display_name: '' }])
expect(chip().textContent).toContain('marta')
expect(chip().textContent).not.toContain('Marta Alves')
expect(document.body.querySelector('[data-slot="fallback"]')?.textContent).toBe('MA')
expect(footer().textContent?.trim()).toBe('You appear as “marta”, your username.')
expect(toastAdd).toHaveBeenCalledWith({
id: 'display-name-saved',
color: 'success',
icon: 'i-lucide-circle-check',
title: 'Display name removed',
description: 'You now appear as marta, your username.',
})
})
})
describe('Save failed: a 500 or a network failure keeps the text and offers a retry', () => {
it.each([
['a 500 response', SERVER_ERROR],
['a network failure', NETWORK_ERROR],
])(
'after %s shows the alert, keeps the value and retries with the same body',
async (_label, failure) => {
const attempts = [failure, () => savedResponse('Marta Alves')]
const { request } = await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
})
await typeInto('Marta Alves')
await clickSave()
const alert = document.body.querySelector('[role="alert"]')!
expect(alert.textContent).toContain(SAVE_FAILED_COPY)
expect(displayNameField().value).toBe('Marta Alves')
expect(displayNameField().disabled).toBe(false)
expect(footer().textContent?.trim()).toBe('Unsaved changes')
expect(isEnabled(saveButton())).toBe(true)
expect(useSessionStore().session?.displayName).toBe('')
expect(toastAdd).not.toHaveBeenCalled()
await clickSave()
expect(patchBodies(request)).toEqual([
{ display_name: 'Marta Alves' },
{ display_name: 'Marta Alves' },
])
expect(document.body.querySelector('[role="alert"]')).toBeNull()
},
)
it('hides the alert while the retry is in flight', async () => {
const retry = deferred()
const attempts = [SERVER_ERROR, () => retry.promise]
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => attempts.shift()!() },
})
await typeInto('Marta Alves')
await clickSave()
expect(document.body.querySelector('[role="alert"]')).not.toBeNull()
await clickSave()
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(saveButton().textContent?.trim()).toBe('Saving…')
})
})
describe('A 400 from the API', () => {
it('shows the field error and disables Save for the display-name message', async () => {
await mountApp({
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
Promise.reject(new ApiError(400, undefined, LIMIT_BODY)),
},
})
await typeInto('Marta Alves')
await clickSave()
expect(byTestId('display-name-error')?.textContent).toContain('Use 60 characters or fewer.')
expect(isEnabled(saveButton())).toBe(false)
expect(document.body.querySelector('[role="alert"]')).toBeNull()
})
it('shows the Save failed alert and keeps the text for any other 400', async () => {
await mountApp({
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () =>
Promise.reject(new ApiError(400, undefined, 'invalid request body')),
},
})
await typeInto('Marta Alves')
await clickSave()
expect(document.body.querySelector('[role="alert"]')?.textContent).toContain(
SAVE_FAILED_COPY,
)
expect(displayNameField().value).toBe('Marta Alves')
expect(byTestId('display-name-error')).toBeNull()
})
})
describe('Leaving with unsaved changes asks for confirmation', () => {
it('opens a neutral ConfirmDialog, stays on Keep editing and leaves on Discard changes', async () => {
await mountApp()
await typeInto('Marta Alves')
const first = router.push('/users')
await flushPromises()
const props = overlayMock.getProps()!
expect(props.title).toBe('Leave without saving?')
expect(props.description).toBe("Your display name change hasn't been saved.")
expect(props.confirmLabel).toBe('Keep editing')
expect(props.cancelLabel).toBe('Discard changes')
expect(props.tone).toBe('neutral')
overlayMock.resolveOpen(true)
await first
expect(router.currentRoute.value.path).toBe('/profile')
expect(displayNameField().value).toBe('Marta Alves')
const second = router.push('/users')
await flushPromises()
overlayMock.resolveOpen(false)
await second
expect(router.currentRoute.value.path).toBe('/users')
expect(useSessionStore().session?.displayName).toBe('')
})
it('opens nothing when nothing is unsaved', async () => {
await mountApp()
await router.push('/users')
await flushPromises()
expect(overlayMock.create).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/users')
})
it('opens the dialog when Log out is used with a dirty draft', async () => {
const { request } = await mountApp()
await typeInto('Marta Alves')
chip().click()
await flushPromises()
const logOut = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent?.trim() === 'Log out')!
activate(logOut)
await flushPromises()
expect(callsTo(request, LOGOUT_PATH, 'POST')).toHaveLength(1)
expect(overlayMock.create).toHaveBeenCalledTimes(1)
})
it('redirects without the dialog when a 401 while saving is handled globally', async () => {
await mountApp({
handlers: {
[`PATCH ${DISPLAY_NAME_PATH}`]: () => {
authRedirectPending.value = true
router.push({
path: '/login',
query: { redirect: '/profile', reason: 'expired' },
})
return Promise.reject(new ApiError(401))
},
},
})
await typeInto('Marta Alves')
await clickSave()
await flushPromises()
expect(overlayMock.create).not.toHaveBeenCalled()
expect(router.currentRoute.value.fullPath).toBe('/login?redirect=/profile&reason=expired')
expect(document.body.textContent).not.toContain(SAVE_FAILED_COPY)
})
})
describe('The Home greeting follows a display name saved on /profile', () => {
it('shows the saved name on /home without a reload', async () => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
await mountApp({
handlers: { [`PATCH ${DISPLAY_NAME_PATH}`]: () => savedResponse('Marta Alves') },
})
await typeInto('Marta Alves')
await clickSave()
await router.push('/home')
await flushPromises()
expect(document.body.querySelector('[data-testid="greeting"]')?.textContent?.trim()).toBe(
'Good morning, Marta Alves',
)
expect(chip().textContent).toContain('Marta Alves')
vi.restoreAllMocks()
})
})
describe('Reset: sending, sent and Send again', () => {
it('posts the session email with no confirmation and shows Sending… with no note', async () => {
const pending = deferred()
const { request } = await mountApp({
session: { ...MARTA, displayName: 'Marta Alves' },
handlers: { [`POST ${RESET_PATH}`]: () => pending.promise },
})
await clickReset()
const [call] = callsTo(request, RESET_PATH, 'POST')
expect(JSON.parse((call![1] as RequestInit).body as string)).toEqual({
email: 'marta@renew.example',
})
expect(resetButton().textContent?.trim()).toBe('Sending…')
expect(resetButton().disabled).toBe(true)
expect(byTestId('reset-note')).toBeNull()
expect(chip().textContent).toContain('Marta Alves')
})
it('shows the sent note, sends again with the note hidden meanwhile, and resets on remount', async () => {
const second = deferred()
const responses = [() => Promise.resolve({ message: 'ok' }), () => second.promise]
const { request } = await mountApp({
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
})
await clickReset()
expect(resetButton().textContent?.trim()).toBe('Send again')
expect(resetButton().disabled).toBe(false)
const note = byTestId('reset-note')!
expect(note.getAttribute('role')).toBe('status')
expect(note.textContent).toContain('Reset link sent')
expect(note.textContent).toContain(
'Check marta@renew.example. The link works once, within 1 hour.',
)
await clickReset()
expect(callsTo(request, RESET_PATH, 'POST')).toHaveLength(2)
expect(byTestId('reset-note')).toBeNull()
second.resolve({ message: 'ok' })
await flushPromises()
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
await router.push('/home')
await flushPromises()
await router.push('/profile')
await flushPromises()
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(byTestId('reset-note')).toBeNull()
})
})
describe('Reset: too many requests disables the button for the Retry-After window', () => {
it.each([
['a Retry-After of 42', 42, 42],
['a Retry-After of 300', 300, 300],
['no Retry-After header', undefined, 300],
['a Retry-After of 0', 0, 300],
])(
'with %s the button is enabled again after %i seconds',
async (_label, retryAfter, seconds) => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
await mountApp({
handlers: {
[`POST ${RESET_PATH}`]: () => Promise.reject(new ApiError(429, retryAfter)),
},
})
await clickReset()
const note = byTestId('reset-note')!
expect(note.getAttribute('role')).toBe('status')
expect(note.textContent).toContain('Too many requests')
expect(note.textContent).toContain(
"You've asked for several links recently. Wait a few minutes, then try again.",
)
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(resetButton().disabled).toBe(true)
vi.advanceTimersByTime((seconds - 1) * 1000)
await flushPromises()
expect(resetButton().disabled).toBe(true)
vi.advanceTimersByTime(1000)
await flushPromises()
expect(resetButton().disabled).toBe(false)
expect(byTestId('reset-note')).toBeNull()
},
)
})
describe('Reset: failed leaves the button pressable', () => {
it.each([
['a 500 response', SERVER_ERROR],
['a network failure', NETWORK_ERROR],
])(
'after %s shows the error note and recovers on a successful retry',
async (_label, failure) => {
const responses = [failure, () => Promise.resolve({ message: 'ok' })]
await mountApp({
handlers: { [`POST ${RESET_PATH}`]: () => responses.shift()!() },
})
await clickReset()
const note = byTestId('reset-note')!
expect(note.getAttribute('role')).toBe('status')
expect(note.textContent).toContain("Couldn't send the link")
expect(note.textContent).toContain('Check your connection and try again.')
expect(resetButton().textContent?.trim()).toBe('Send reset link')
expect(resetButton().disabled).toBe(false)
await clickReset()
expect(byTestId('reset-note')?.textContent).toContain('Reset link sent')
expect(resetButton().textContent?.trim()).toBe('Send again')
},
)
})
+26
View File
@@ -288,6 +288,32 @@ describe('Default — the list loads newest first, with display name, "you" tag
})
})
describe('Display names in the Users list', () => {
it('shows each display name with its username beside it and the chip with the session name', async () => {
const fixture = defaultFixture().map((item) =>
item.kind === 'user' && item.username === 'marta'
? { ...item, display_name: 'Marta Alves' }
: item,
)
await mountPage({ list: () => Promise.resolve(fixture) })
useSessionStore().session = {
verified: true,
username: 'marta',
id: 1,
displayName: 'Marta Alves',
}
await flushPromises()
expect(rowTexts().slice(2)).toEqual([
'Inès Duarte ines Accepted 18 Jun 2026',
'jonas Accepted 2 Apr 2026',
'Marta Alves marta you Accepted 12 Mar 2026',
])
const footer = document.body.querySelector('[data-slot="footer"]') as HTMLElement
expect(footer.textContent).toContain('Marta Alves')
})
})
describe('Loading — skeleton rows after 150 ms', () => {
it('draws nothing until 150 ms, then five skeleton rows, then the rows', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
+52 -146
View File
@@ -6,11 +6,7 @@ import router from '@/router'
import { useSessionStore } from '@/stores/session'
import VerifyView from '../VerifyView.vue'
function mockResponse(
status: number,
body: unknown = null,
headers: Record<string, string> = {},
) {
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
ok: status >= 200 && status < 300,
status,
@@ -24,9 +20,7 @@ function routeFor(url: string, init?: RequestInit) {
return `${method} ${url}`
}
function fetchRouter(
handlers: Record<string, () => Response | Promise<Response>>,
) {
function fetchRouter(handlers: Record<string, () => Response | Promise<Response>>) {
return vi.fn((url: string, init?: RequestInit) => {
const key = routeFor(url, init)
const handler = handlers[key]
@@ -81,16 +75,11 @@ afterEach(async () => {
describe('Verifying — the code submits automatically on load', () => {
it('shows the full-card spinner and calls verify once with the code', async () => {
const verify = vi.fn(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
},
)
})
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(verify).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
@@ -119,16 +108,13 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
description: 'Welcome to RenewCMS.',
})
await vi.waitFor(
() => expect(router.currentRoute.value.path).toBe('/home'),
{
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'), {
timeout: 2000,
},
)
})
expect(toastAdd).toHaveBeenCalledTimes(1)
const session = useSessionStore()
expect(session.session).toEqual({ verified: true })
expect(session.session).toMatchObject({ verified: true, displayName: '' })
})
})
@@ -152,9 +138,7 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
expect(wrapper.text()).toContain(
'Cancel your registration and start over to get a new link.',
)
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
@@ -165,27 +149,19 @@ describe('Invalid or expired — 403 offers a way to start over', () => {
describe('Cancel registration and start over — success', () => {
it('sends DELETE with onForbidden passthrough, navigates to /register and toasts', async () => {
const deleteRegister = vi.fn(() => mockResponse(200))
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': deleteRegister,
},
)
})
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/register',
expect.objectContaining({ method: 'DELETE' }),
)
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/register'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register'))
expect(toastAdd).toHaveBeenCalledWith({
color: 'success',
icon: 'i-lucide-circle-check',
@@ -206,38 +182,25 @@ describe('Cancel registration and start over — failure shows an alert, not a r
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])(
'shows the failure alert and keeps the panel for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
])('shows the failure alert and keeps the panel for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(403),
'DELETE /v1/auth/register': handler,
},
)
})
await findButton(
wrapper,
'Cancel registration and start over',
)?.trigger('click')
await findButton(wrapper, 'Cancel registration and start over')?.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't cancel registration")
expect(wrapper.text()).toContain(
'Something went wrong on the server. Try again in a moment.',
)
expect(wrapper.text()).toContain(
"This verification link doesn't work",
)
expect(
findButton(wrapper, 'Cancel registration and start over'),
).toBeTruthy()
expect(wrapper.text()).toContain("This verification link doesn't work")
expect(findButton(wrapper, 'Cancel registration and start over')).toBeTruthy()
expect(findButton(wrapper, 'Log out')).toBeTruthy()
expect(router.currentRoute.value.path).toBe('/register/validate')
expect(toastAdd).not.toHaveBeenCalled()
},
)
})
})
describe('Log out from the Invalid-or-expired state', () => {
@@ -254,9 +217,7 @@ describe('Log out from the Invalid-or-expired state', () => {
await flushPromises()
expect(session.session).toBeNull()
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
@@ -270,15 +231,9 @@ describe('No session — the inline log-in form replaces the spinner', () => {
expect(wrapper.text()).toContain(
"You opened this link somewhere you're not logged in. Log in here and we'll verify your account right after.",
)
expect(
wrapper.find('input[placeholder="Your username"]').exists(),
).toBe(true)
expect(
wrapper.find('input[autocomplete="current-password"]').exists(),
).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
expect(wrapper.find('input[placeholder="Your username"]').exists()).toBe(true)
expect(wrapper.find('input[autocomplete="current-password"]').exists()).toBe(true)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
expect(wrapper.text()).not.toContain('Forgot password?')
expect(wrapper.text()).not.toContain('First time here?')
expect(router.currentRoute.value.path).toBe('/register/validate')
@@ -310,26 +265,17 @@ describe('No session — successful login retries verification with the same cod
const verify = vi.fn()
verify.mockResolvedValueOnce(mockResponse(401))
verify.mockImplementationOnce(() => new Promise(() => {}))
const { wrapper, fetchMock } = await mountVerify(
'/register/validate?c=abc123',
{
const { wrapper, fetchMock } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': verify,
'POST /v1/auth/login': () => mockResponse(200),
},
)
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.find('[role="status"]').text()).toContain(
'Verifying your account…',
)
expect(wrapper.find('[role="status"]').text()).toContain('Verifying your account…')
expect(fetchMock).toHaveBeenCalledWith(
'/v1/auth/verify',
expect.objectContaining({
@@ -349,24 +295,14 @@ describe('No session — wrong credentials during the inline login', () => {
'POST /v1/auth/login': () => mockResponse(403),
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('wrong-password')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('wrong-password')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain(
"That username and password don't match",
)
expect(wrapper.text()).toContain(
'Check both and try again. Passwords are case-sensitive.',
)
const passwordInput = wrapper.find(
'input[autocomplete="current-password"]',
)
expect(wrapper.text()).toContain("That username and password don't match")
expect(wrapper.text()).toContain('Check both and try again. Passwords are case-sensitive.')
const passwordInput = wrapper.find('input[autocomplete="current-password"]')
expect((passwordInput.element as HTMLInputElement).value).toBe('')
expect(passwordInput.element).toBe(document.activeElement)
expect(verify).toHaveBeenCalledTimes(1)
@@ -378,23 +314,16 @@ describe('No session — too many login attempts', () => {
vi.useFakeTimers()
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': () =>
mockResponse(429, null, { 'Retry-After': '30' }),
'POST /v1/auth/login': () => mockResponse(429, null, { 'Retry-After': '30' }),
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Too many attempts')
expect(wrapper.text()).toContain(
'For your security, wait a moment before trying again.',
)
expect(wrapper.text()).toContain('For your security, wait a moment before trying again.')
const button = wrapper.find('button[type="submit"]')
expect(button.text()).toBe('Try again in 0:30')
expect((button.element as HTMLButtonElement).disabled).toBe(true)
@@ -402,9 +331,7 @@ describe('No session — too many login attempts', () => {
await vi.advanceTimersByTimeAsync(30000)
await flushPromises()
expect(wrapper.find('button[type="submit"]').text()).toBe(
'Log in and verify',
)
expect(wrapper.find('button[type="submit"]').text()).toBe('Log in and verify')
})
})
@@ -415,50 +342,31 @@ describe('No session — server or network error during the inline login', () =>
name: 'a network failure',
handler: () => Promise.reject(new TypeError('Failed to fetch')),
},
])(
'shows the network/server alert and keeps the fields for $name',
async ({ handler }) => {
const { wrapper } = await mountVerify(
'/register/validate?c=abc123',
{
])('shows the network/server alert and keeps the fields for $name', async ({ handler }) => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(401),
'POST /v1/auth/login': handler,
},
)
})
await wrapper
.find('input[placeholder="Your username"]')
.setValue('marta')
await wrapper
.find('input[autocomplete="current-password"]')
.setValue('limewash2026')
await wrapper.find('input[placeholder="Your username"]').setValue('marta')
await wrapper.find('input[autocomplete="current-password"]').setValue('limewash2026')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain("Couldn't reach RenewCMS")
expect(wrapper.text()).toContain(
'Check your connection and try again.',
)
expect(wrapper.text()).toContain('Check your connection and try again.')
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).value,
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement).value,
).toBe('marta')
expect(
(
wrapper.find('input[autocomplete="current-password"]')
.element as HTMLInputElement
).value,
(wrapper.find('input[autocomplete="current-password"]').element as HTMLInputElement)
.value,
).toBe('limewash2026')
expect(
(
wrapper.find('input[placeholder="Your username"]')
.element as HTMLInputElement
).disabled,
(wrapper.find('input[placeholder="Your username"]').element as HTMLInputElement)
.disabled,
).toBe(false)
},
)
})
})
describe('Missing code — no request is made', () => {
@@ -475,8 +383,6 @@ describe('Missing code — no request is made', () => {
expect(action).toBeTruthy()
await action?.trigger('click')
await vi.waitFor(() =>
expect(router.currentRoute.value.path).toBe('/login'),
)
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/login'))
})
})
+165 -1
View File
@@ -152,7 +152,13 @@ const sixArticlesFixture = [
const verifiedSession: ApiMock = {
status: 200,
body: { id: 1, is_verified: true, username: 'marta' },
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: '',
},
}
const editorNow = '2026-09-26T11:02:00Z'
@@ -667,7 +673,165 @@ function submitAcceptInvite(username: string, password: string) {
}
}
const profileDisplayName = 'Marta Alves'
const profileFieldPlaceholder = 'e.g. Marta Alves'
const profileTooLongName = 'Marta Alves, Head of Renovations, Brussels and Wallonia office'
const profileSavedBody = {
id: 1,
username: 'marta',
display_name: profileDisplayName,
}
function profileSession(displayName = ''): ApiMock {
return {
status: 200,
body: {
id: 1,
is_verified: true,
username: 'marta',
email: 'marta@renew.example',
display_name: displayName,
},
}
}
function profileApi(
displayName = '',
overrides: Record<string, ApiMock> = {},
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': profileSession(displayName),
...overrides,
}
}
function profileState(
displayName = '',
overrides: Record<string, ApiMock> = {},
steps?: (page: Page) => Promise<void>,
): VisualState {
return { path: '/profile', api: profileApi(displayName, overrides), steps }
}
async function typeDisplayName(page: Page, value: string) {
await page.getByPlaceholder(profileFieldPlaceholder).fill(value)
}
async function scrollToTop(page: Page) {
await page.evaluate(() => {
window.scrollTo(0, 0)
document.querySelectorAll<HTMLElement>('[data-slot="body"]').forEach((element) => {
element.scrollTop = 0
})
})
}
async function pressSave(page: Page) {
await page.getByRole('button', { name: 'Save', exact: true }).click()
}
async function saveDisplayName(page: Page, value: string) {
await typeDisplayName(page, value)
await pressSave(page)
await page.mouse.move(0, 0)
}
async function pressSendResetLink(page: Page) {
await page.getByRole('button', { name: 'Send reset link', exact: true }).click()
await page.mouse.move(0, 0)
await scrollToTop(page)
}
function resetState(response: ApiMock): VisualState {
return profileState(
profileDisplayName,
{ 'POST /v1/auth/password-reset': response },
pressSendResetLink,
)
}
async function openAccountMenu(page: Page) {
const isMobile = (page.viewportSize()?.width ?? 0) < 1024
if (isMobile) {
await page.locator('[data-slot="toggle"]').click()
await page.locator('[role="dialog"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor()
} else {
await page.locator('[data-slot="footer"] button[aria-haspopup]').click()
await page.locator('[role="menu"]').waitFor()
}
await page.mouse.move(0, 0)
}
const usersWithDisplayNames = usersFixture.map((item) =>
item.kind === 'user' && item.username === 'marta'
? { ...item, display_name: profileDisplayName }
: item,
)
export const visualStates: Record<string, Record<string, VisualState>> = {
profile: {
'Account menu': {
path: '/home',
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
},
steps: openAccountMenu,
exception: {
maxPercent: 26,
reason: 'Mobile sheet is ~153px taller than the board because the System/Light/Dark items are kept by user decision, and the board draws Profile highlighted on /home while the feature highlights it only on /profile',
approvedBy: 'Florian Sylvain, 2026-09-29',
},
},
Loading: {
path: '/profile',
api: { 'GET /v1/auth/me': { status: 200, hang: true } },
},
'Default, no display name': profileState(),
Editing: profileState('', {}, async (page) => {
await typeDisplayName(page, profileDisplayName)
await page.mouse.move(0, 0)
}),
'Too long': profileState('', {}, async (page) => {
await typeDisplayName(page, profileTooLongName)
await page.mouse.move(0, 0)
}),
Saving: profileState(
'',
{ 'PATCH /v1/user/me': { status: 200, body: profileSavedBody, hang: true } },
async (page) => {
await saveDisplayName(page, profileDisplayName)
},
),
Saved: profileState(
'',
{ 'PATCH /v1/user/me': { status: 200, body: profileSavedBody } },
async (page) => {
await saveDisplayName(page, profileDisplayName)
await page.getByText('Display name saved').first().waitFor()
},
),
'Save failed': profileState('', { 'PATCH /v1/user/me': { status: 500 } }, async (page) => {
await saveDisplayName(page, profileDisplayName)
await page.getByText("Couldn't save your display name").waitFor()
}),
'Reset: sending': resetState({ status: 200, hang: true }),
'Reset: sent': resetState({ status: 200, body: { message: 'ok' } }),
'Reset: too many requests (429)': resetState({
status: 429,
headers: { 'Retry-After': '300' },
}),
'Reset: failed': resetState({ status: 500 }),
'Display names in the Users list': {
path: '/users',
api: {
'GET /v1/auth/me': profileSession(profileDisplayName),
'GET /v1/user/': { status: 200, body: usersWithDisplayNames },
},
},
},
users: {
Default: { path: '/users', api: usersApi() },
Loading: {