feat: settings page, sidebar and drawer destination "Settings" (icon settings, after Integration), route /settings (verified), showing only the running build's Version

This commit is contained in:
fsylvain committed 2026-09-30 00:19:29 +02:00
1 parent b146dfead7
commit 162fea4afb
26 files changed
+805 -45

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ Feature: DashboardShell wordmark, sidebar collapse and the UserMenu
AuthShell uses) followed by the "RenewCMS" wordmark text, not the plain
<span class="font-semibold">RenewCMS</span> the shell renders today
And the sidebar's #default slot is a UNavigationMenu orientation="vertical" :collapsed bound
to the sidebar's own collapsed state, with items Home, Articles, Users and Integration, in that order
to the sidebar's own collapsed state, with items Home, Articles, Users, Integration and Settings, 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 the account menu specified in features/profile_page.feature, anchored to a
+5 -5
View File
@@ -130,14 +130,14 @@ Feature: Nuxt UI shell foundation and router guards
Then the alert above the title contains "Your session expired."
And the footer area outside the card contains a "Forgot password?" link
Scenario: DashboardShell renders the four navigation destinations
Scenario: DashboardShell renders the five navigation destinations
Given the current route is /article
When DashboardShell mounts
Then the sidebar navigation lists exactly four destinations in this order: "Home" linking to
/home, "Articles" linking to /article, "Users" linking to /users, and "Integration" linking
to /integration
Then the sidebar navigation lists exactly five destinations in this order: "Home" linking to
/home, "Articles" linking to /article, "Users" linking to /users, "Integration" linking
to /integration, and "Settings" linking to /settings
And the "Articles" destination has aria-current="page"
And none of "Home", "Users" and "Integration" has aria-current set
And none of "Home", "Users", "Integration" and "Settings" has aria-current set
Scenario: DashboardShell shows the offline banner when the browser reports offline
Given navigator.onLine is false
+2 -2
View File
@@ -41,7 +41,7 @@ Mock `useApi` / the `/v1/auth/me` call per case; no real backend, no httptest se
13. **AuthShell notice/footer slots** — mount AuthShell with notice and footer slot content; assert
both render outside/above the card content as specified.
14. **DashboardShell nav + aria-current** — mount DashboardShell with current route /article; assert
4 links in order (Home→/home, Articles→/article, Users→/users, Integration→/integration), and only the Articles link
5 links in order (Home→/home, Articles→/article, Users→/users, Integration→/integration, Settings→/settings), and only the Articles link
has aria-current="page".
15. **DashboardShell offline banner** — mock navigator.onLine false, mount DashboardShell; assert
the offline banner is visible; set navigator.onLine true and dispatch the 'online' event; assert
@@ -57,7 +57,7 @@ plus the components.md AuthShell/DashboardShell sections.
- [ ] AuthShell below sm: card loses border, 20px padding fills screen, fields/buttons use xl size,
light and dark.
- [ ] AuthShell footer: UColorModeSelect size="sm" plus footer links render outside the card.
- [ ] DashboardShell desktop: sidebar with RenewCMS logo header, 3 nav items, UserMenu footer;
- [ ] DashboardShell desktop: sidebar with RenewCMS logo header, 5 nav items, UserMenu footer;
collapsing the sidebar leaves a 56px icon rail with tooltips on hover/focus.
- [ ] DashboardShell mobile (below lg): UDashboardSidebarToggle appears, sidebar becomes a
slideover, closes after navigating.
+2 -2
View File
@@ -18,8 +18,8 @@ Feature: Profile page, display name and the account menu
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
"Profile" and the subtitle "Your account on this RenewCMS workspace."; none of the five
navigation items (Home, Articles, Users, Integration, Settings) has aria-current="page" on /profile
And the page is at most 720 px wide with three ProfileCards in this order: Display name,
Account, Password; cards are separated by 20 px, have 20 px padding (16 px on mobile) and
inputs are 40 px high (44 px on mobile, 16 px text)
+131
View File
@@ -0,0 +1,131 @@
Feature: Settings page and GET /v1/settings
As a verified RenewCMS user in the shared workspace
I want a Settings page that shows the version of the RenewCMS build I am running
So that I can tell which build is deployed when I report a problem or check an upgrade
Background:
Given GET /v1/settings is mounted under /v1 in api/router.go in its own router, behind
jwtauth.Verifier, jwtauth.Authenticator, middleware.RequireCurrentToken and
middleware.RequireVerifiedUser, the same chain as NewUserRouter
And the response of a verified caller is 200 with the JSON body {"version": "<value>"}, one
snake_case field and nothing else, built from the domain entity settings.Settings in
internal/domain/settings
And GetSettingsUseCase (internal/infrastructure/useCases) is built with the version string and
depends only on the domain package; the handler in api/handlers/settings receives it
through NewHandler and never reads config, os or the environment
And config.Settings gains a Version field; cmd/server/main.go sets it from its own
main.version variable ("dev" unless set with -ldflags -X main.version) right after
config.NewSettingsFromEnv, and api/dependecyInjection.go passes settings.Version to the use
case; no os.Getenv or os.LookupEnv for the version appears outside cmd/server/main.go and
internal/infrastructure/config, and the version is captured once at startup
And /settings is a verified route (meta.auth "verified", meta.title "Settings") rendered as
SettingsPage inside DashboardShell, per docs/design/routes.md and docs/design/screens/settings.md
And SettingsPage calls GET /v1/settings once per navigation to it
And the PageHeader shows breadcrumb "Settings" (mobile title "Settings"), the page title
"Settings" and the subtitle "Workspace information."
And the page is at most 720 px wide, with padding 32 px on desktop and 20px 16px 88px on
mobile, and 20 px between the header and the card
And the page holds one card, --ui-bg with a 1 px --ui-border, 20 px padding on desktop and
16 px on mobile, containing a single row laid out as the board frame draws it: the label at
the left, the value at the right, vertically centred, 16 px apart
And the label is "Version" (15 px semibold, --ui-text-highlighted) and the value is the
server's version string in IBM Plex Mono 14 px, --ui-text-toned, with no "v" added by the
SPA
And the page has no action, toggle, link or input; every verified user sees it, owner and
admin alike
And DashboardShell's navigation gains "Settings" (icon settings, i-lucide-settings, linking to
/settings) after "Integration", in the sidebar and in the mobile drawer, so it lists five
destinations in this order: Home, Articles, Users, Integration, Settings
And skeleton placeholders show only after 150 ms of loading, so a fast response never flashes
them
And the Loading and Error states are not drawn in docs/design; they are decided in this spec
and covered by Vitest only (no visual state)
Scenario Outline: A verified caller gets the running build's version
Given the process was started with a config.Settings whose Version is "<version>"
And a verified user is logged in with a valid jwt cookie
When the client sends GET /v1/settings
Then the response status is 200
And the response body is exactly the JSON object {"version": "<version>"}
Examples:
| version |
| v2.2.0-beta |
| dev |
Scenario: The use case returns the version it was built with
Given GetSettingsUseCase is built with the version "v2.2.0-beta"
When GetSettings is called
Then it returns a settings.Settings whose Version is "v2.2.0-beta"
Scenario: A request without a jwt cookie is refused
When the client sends GET /v1/settings with no jwt cookie
Then the response status is 401
And the use case is not consulted for the response
Scenario: A token whose version no longer matches the account is refused
Given user id 7 exists with token version 1
And the jwt cookie was minted for user 7 with token version 0
When the client sends GET /v1/settings with that cookie
Then the response status is 401
And the response body is apperr.UnauthorizedErrorMessage
Scenario: An unverified account is refused
Given user id 8 exists with is_verified false and a valid jwt cookie
When the client sends GET /v1/settings with that cookie
Then the response status is 403
And the response body is apperr.VerificationRequiredErrorMessage
And the response does not contain the version
Scenario: Default — the Version row shows the server's version
Given GET /v1/settings resolves 200 with {"version": "v2.2.0-beta"}
When the user navigates to /settings
Then the breadcrumb reads "Settings" (mobile title "Settings")
And the header shows the title "Settings" and the subtitle "Workspace information."
And the card shows one row: the label "Version" and the value "v2.2.0-beta"
And the "Settings" nav item has aria-current="page" and no other nav item does
Scenario: The value is shown exactly as reported, with no prefix added
Given GET /v1/settings resolves 200 with {"version": "dev"}
When the user navigates to /settings
Then the value reads "dev"
Scenario: Navigation — "Settings" sits after Integration
Given the user is on /home
When DashboardShell mounts
Then the sidebar and the mobile drawer list Home, Articles, Users, Integration, Settings in
that order
And "Settings" has the settings icon and links to /settings
Scenario: Loading — the value becomes a skeleton after 150 ms while the request is in flight
Given GET /v1/settings has not yet resolved
When the user navigates to /settings
Then the label "Version" is visible at once, and nothing stands in for the value during the
first 150 ms
And after 150 ms a skeleton replaces the value only, and the card is aria-busy="true"
When GET /v1/settings then resolves 200 with {"version": "v2.2.0-beta"}
Then the skeleton is replaced by "v2.2.0-beta" and aria-busy is no longer set
Scenario Outline: Error — a 500 or a network failure replaces the row with an inline alert and a retry
Given GET /v1/settings <failure>
When the user navigates to /settings
Then the card shows an error alert with no close button and no title, reading "Couldn't reach
RenewCMS. Check your connection and try again.", with an action "Try again" (icon rotate-cw)
And the "Version" row is not shown
And the header (title and subtitle) is still shown
When the user activates "Try again"
Then the Loading state shows and GET /v1/settings is sent again
When it then resolves 200 with {"version": "v2.2.0-beta"}
Then the Default state's row replaces the alert
Examples:
| failure |
| resolves with a 500 response |
| never reaches the server |
Scenario: A 401 or an unverified 403 is handled globally, not by the page
Given GET /v1/settings resolves 401
When the user navigates to /settings
Then useApi's global handling redirects to "/login?redirect=/settings&reason=expired"
And SettingsPage shows no error alert of its own
+92
View File
@@ -0,0 +1,92 @@
# QA: Settings page and GET /v1/settings
## Automated Go (`task test`, package `test`, black-box)
Files: `test/settings_test.go` (use case) and `test/settings_http_test.go` (HTTP). Fakes: reuse
`mockUserRepo` from `test/user_test.go`; no settings repository exists, so none is needed. Name any
new fake uniquely, since all fakes share one package.
Setup shared by the HTTP cases:
1. Call `validator.InitValidator()` once.
2. Build `token := jwtauth.New("HS256", []byte("test-secret"), nil)`, `getUser :=
useCases.NewGetUserUseCase(userRepo)` and `settingsHandler :=
settings.NewHandler(useCases.NewGetSettingsUseCase(version))`.
3. Mount on a fresh `chi.NewRouter()` mirroring `NewSettingsRouter`:
`r.Route("/v1/settings", ...)` with `jwtauth.Verifier(token)`, `jwtauth.Authenticator(token)`,
`middleware.RequireCurrentToken(getUser)`, `middleware.RequireVerifiedUser(getUser)`, then
`r.Get("/", settingsHandler.GetSettings)`. No live server, no SQLite, no SMTP.
4. Mint cookies with `token.Encode(map[string]any{"user_id": uint32(id), "token_version":
uint32(v)})`, as in `test/stale_token_invalidation_http_test.go`.
Cases:
5. Use case, table-driven over versions "v2.2.0-beta" and "dev": `NewGetSettingsUseCase(v)
.GetSettings().Version == v`. The rule that `internal/domain/settings` and the use case do not
import `config`, `os`, `net/http` or `gorm` is a review item, not a Go test.
6. Verified user id 7 (`IsVerified: true`, `TokenVersion: 0`), matching cookie, version
"v2.2.0-beta": `GET /v1/settings` returns 200 and the body decodes to exactly one key
`version` equal to "v2.2.0-beta" (decode into `map[string]any` and assert `len == 1`). Repeat
with version "dev".
7. No cookie: 401. Do not assert an exact body (it is produced by `jwtauth.Authenticator`).
8. User 7 with `TokenVersion: 1`, cookie encoding `token_version: 0`: 401, body exactly
`apperr.UnauthorizedErrorMessage`.
9. User 8 with `IsVerified: false`, matching cookie: 403, body exactly
`apperr.VerificationRequiredErrorMessage`, and the body does not contain the version string.
10. After building the router, call `t.Setenv("ENVIRONMENT", "development")` and re-request as in
case 6: the response is unchanged (nothing is read from the environment at request time).
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi`'s `request` for `GET /v1/settings`. Mount SettingsPage inside DashboardShell with a
resolved verified session and a test router at /settings. Use fake timers for the 150 ms cases. No
test freezes the system date.
1. **Default** — resolve `{ version: 'v2.2.0-beta' }`; assert breadcrumb "Settings", title
"Settings", subtitle "Workspace information.", one row with the label "Version" and the value
exactly "v2.2.0-beta", `request` called once with `/v1/settings`, and no button, link, switch or
input inside the card.
2. **No prefix** — resolve `{ version: 'dev' }`; assert the value text is exactly "dev".
3. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration,
Settings in that order with the settings icon on the last; on /settings only "Settings" carries
`aria-current="page"`.
4. **Loading** — unresolved request; at 149 ms assert no skeleton and that "Version" is present; at
150 ms assert a skeleton in place of the value and `aria-busy="true"` on the card; resolve and
assert the value and no `aria-busy`.
5. **Error, Examples-driven** — reject with `ApiError(500)`, then `NetworkError`; assert the alert
text "Couldn't reach RenewCMS. Check your connection and try again.", the "Try again" action, no
close button, no title and no "Version" row, with the header still shown. Click "Try again";
assert Loading and a second `request`; resolve and assert the Default row.
6. **401** — reject with `ApiError(401)`; assert the page shows no alert of its own (the redirect
itself is covered by `useApi`'s existing tests).
7. **Router meta** — assert the `/settings` route has `meta.auth === 'verified'` and
`meta.title === 'Settings'`.
8. **Amended shell specs** — update the existing Vitest for `nuxt_ui_shell_foundation` case 14,
`dashboard_shell_branding`, users_screen and profile_page to five nav items, in the same change.
## Visual (`task visual:check -- settings`, desktop and mobile, light and dark)
Reference: `docs/design/screens/settings.md`; board group "Settings" in `docs/design/source/RenewCMS
Screens Back-office.dc.html`; exact values from the `isSettings` block of
`docs/design/source/AppFrame.dc.html`. Add the mock to `web/visual/states.ts`. The gate is hard:
every capture within 10 %.
- [ ] Default: mock `GET /v1/settings` returning `{ "version": "v2.2.0-beta" }` (the board's
sample). Layout as the frame draws it: the header is a 20/28 semibold title with the muted
subtitle 2 px below; the card is the header's next sibling, 20 px below; the card is
`--ui-bg` with a 1 px `--ui-border` and no drawn radius; the row is a flex row with
`justify-content: space-between`, `align-items: center` and a 16 px gap, the label at the
left and the mono value at the right; the whole page is at most 720 px wide.
- [ ] Not drawn, no visual state: Loading and Error. Do not add them to `web/visual/states.ts`;
Vitest cases 4 and 5 cover them.
- [ ] Not covered by screenshots: keyboard order (Tab reaches the "Settings" nav item; on Error,
Tab reaches "Try again", and Enter and Space activate it); focus after a navigation; the
skeleton pulse under reduced motion.
## Manual verification
- With a build made through `task build` or `task release` (`-ldflags -X main.version=...`),
open /settings and confirm the value equals the injected version. A build without the flag must
show `dev`.
- Confirm against a running `task dev` API that an unverified account is sent to /register/pending
and a logged-out browser to /login?redirect=/settings&reason=expired.
- Confirm the embedded SPA (built without `-tags nofrontend`) serves /settings on a hard refresh,
since Vitest never exercises the embedded frontend.
+3 -3
View File
@@ -18,8 +18,8 @@ Feature: Users screen and InviteUserModal
and rows are keyed by kind and id, because a user and an invite can share a numeric id
And DashboardShell's navigation gains "Users" (icon users, linking to /users) between
"Articles" and "Integration", in the sidebar and in the mobile drawer, so it lists Home,
Articles, Users, Integration in that order; "Users" has aria-current="page" on /users; the
"four navigation destinations" scenario of nuxt_ui_shell_foundation.feature and the items of
Articles, Users, Integration, Settings in that order; "Users" has aria-current="page" on /users; the
"five navigation destinations" scenario of nuxt_ui_shell_foundation.feature and the items of
dashboard_shell_branding.feature are amended accordingly
And the PageHeader shows breadcrumb "Users" (mobile title "Users")
And the page header is the title "Users" with the subtitle "Everyone who can sign in and work
@@ -108,7 +108,7 @@ Feature: Users screen and InviteUserModal
Scenario: Navigation — "Users" sits between Articles and Integration
Given the user is on /users
When DashboardShell mounts
Then the sidebar and the mobile drawer list Home, Articles, Users, Integration in that order
Then the sidebar and the mobile drawer list Home, Articles, Users, Integration, Settings in that order
And "Users" has the users icon, links to /users and has aria-current="page"
Scenario: Loading — skeleton rows after 150 ms while the first request, or a retry, is in flight
+3 -3
View File
@@ -5,7 +5,7 @@
The invitations API is already built and covered by features/invitations.qa.md: this slice has no
Go change, so it has no HTTP-level Go section.
The shell specs were amended to four destinations (Home, Articles, Users, Integration):
The shell specs were amended to five destinations (Home, Articles, Users, Integration, Settings):
nuxt_ui_shell_foundation.feature / .qa.md (case 14) and dashboard_shell_branding.feature. Their
Vitest tests must be updated in the same change.
@@ -25,8 +25,8 @@ part). Run each layout case at a desktop and a below-md width (mock `matchMedia`
1. **Default** — assert breadcrumb "Users", title, subtitle, "Invite user", rows in fixture order,
"Inès Duarte" with "ines" beside it, the "you" tag only on the "marta" row, badge words and
the fixture's absolute dates; assert no accepted row shows an email.
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration in
that order, and "Users" carries aria-current="page" on /users.
2. **Navigation** — assert the sidebar and the drawer list Home, Articles, Users, Integration,
Settings in that order, and "Users" carries aria-current="page" on /users.
3. **Loading** — unresolved `GET`; with fake timers assert nothing is drawn at 149 ms; at 150 ms
assert 5 skeleton rows and `aria-busy="true"`; resolve and assert the Default rows.
4. **Only you** — a single "marta" item; assert the row, the hint text and "Invite user".